feat: added macOS power assertion module to prevent idle-sleep during sessions

- Added MacOSPowerAssertion native module to prevent idle-sleep on macOS during active sessions.
- Integrated power assertion lifecycle management into coding-agent session with automatic start/stop.
- Implemented N-API bindings for macOS IOKit power assertions with RAII cleanup and cross-platform no-op fallback.
- Reformatted indentation from spaces to tabs across Rust chunk module for consistency.
This commit is contained in:
can1357
2026-04-07 03:45:18 +02:00
parent ad4c5917a4
commit 567197c34e
10 changed files with 380 additions and 109 deletions
+87 -88
View File
@@ -47,104 +47,103 @@ fn recurse_nix_attrset(node: Node<'_>) -> Option<RecurseSpec<'_>> {
}
fn recurse_nix_binding_value(node: Node<'_>) -> Option<RecurseSpec<'_>> {
let expression = node.child_by_field_name("expression")?;
if matches!(
expression.kind(),
"attrset_expression" | "let_attrset_expression" | "rec_attrset_expression"
) {
return recurse_nix_attrset(expression);
}
recurse_value_container(node)
let expression = node.child_by_field_name("expression")?;
if matches!(
expression.kind(),
"attrset_expression" | "let_attrset_expression" | "rec_attrset_expression"
) {
return recurse_nix_attrset(expression);
}
recurse_value_container(node)
}
fn classify_nix_binding<'t>(node: Node<'t>, source: &str) -> RawChunkCandidate<'t> {
let name = extract_nix_binding_name(node, source).unwrap_or_else(|| "anonymous".to_string());
let chunk_name = format!("attr_{name}");
let expression = node.child_by_field_name("expression");
if let Some(expression) = expression
&& matches!(
expression.kind(),
"attrset_expression" | "let_attrset_expression" | "rec_attrset_expression"
)
{
return make_container_chunk(node, chunk_name, source, recurse_nix_attrset(expression));
}
make_named_chunk(node, chunk_name, source, recurse_nix_binding_value(node))
let name = extract_nix_binding_name(node, source).unwrap_or_else(|| "anonymous".to_string());
let chunk_name = format!("attr_{name}");
let expression = node.child_by_field_name("expression");
if let Some(expression) = expression
&& matches!(
expression.kind(),
"attrset_expression" | "let_attrset_expression" | "rec_attrset_expression"
) {
return make_container_chunk(node, chunk_name, source, recurse_nix_attrset(expression));
}
make_named_chunk(node, chunk_name, source, recurse_nix_binding_value(node))
}
impl LangClassifier for NixHclClassifier {
fn classify_root<'t>(&self, node: Node<'t>, source: &str) -> Option<RawChunkCandidate<'t>> {
match node.kind() {
// Nix top-level attrsets should recurse into their binding_set so the file exposes
// structural attr chunks instead of a single opaque attrset_expr leaf.
"attrset_expression" | "let_attrset_expression" | "rec_attrset_expression" => {
Some(make_container_chunk(
node,
sanitize_node_kind(node.kind()),
source,
recurse_nix_attrset(node),
))
},
// Older tree-sitter-nix revisions used `attribute`; current grammars expose `binding`.
"attribute" | "binding" => Some(classify_nix_binding(node, source)),
// HCL top-level block, or diff hunk fallback
"block" => {
if let Some(name) = extract_hcl_block_name(node, source) {
Some(make_container_chunk(
node,
format!("block_{name}"),
source,
recurse_into(node, ChunkContext::ClassBody, &[], &["body"]),
))
} else {
Some(group_candidate(node, "hunks", source))
}
},
// Nix expressions
"function_expression" | "let_expression" => {
Some(named_candidate(node, "expr", source, recurse_value_container(node)))
},
// Nix inherit
"inherit" => Some(group_candidate(node, "imports", source)),
// Variable/assignment declarations
"variable_declaration" | "assignment" => Some(group_candidate(node, "decls", source)),
// HCL top-level block types
"provider" | "resource" | "data" | "locals" | "variable" | "output" | "module" => {
Some(container_candidate(
node,
sanitize_node_kind(node.kind()).as_str(),
source,
recurse_into(node, ChunkContext::ClassBody, &[], &["body"]),
))
},
_ => None,
}
match node.kind() {
// Nix top-level attrsets should recurse into their binding_set so the file exposes
// structural attr chunks instead of a single opaque attrset_expr leaf.
"attrset_expression" | "let_attrset_expression" | "rec_attrset_expression" => {
Some(make_container_chunk(
node,
sanitize_node_kind(node.kind()),
source,
recurse_nix_attrset(node),
))
},
// Older tree-sitter-nix revisions used `attribute`; current grammars expose `binding`.
"attribute" | "binding" => Some(classify_nix_binding(node, source)),
// HCL top-level block, or diff hunk fallback
"block" => {
if let Some(name) = extract_hcl_block_name(node, source) {
Some(make_container_chunk(
node,
format!("block_{name}"),
source,
recurse_into(node, ChunkContext::ClassBody, &[], &["body"]),
))
} else {
Some(group_candidate(node, "hunks", source))
}
},
// Nix expressions
"function_expression" | "let_expression" => {
Some(named_candidate(node, "expr", source, recurse_value_container(node)))
},
// Nix inherit
"inherit" => Some(group_candidate(node, "imports", source)),
// Variable/assignment declarations
"variable_declaration" | "assignment" => Some(group_candidate(node, "decls", source)),
// HCL top-level block types
"provider" | "resource" | "data" | "locals" | "variable" | "output" | "module" => {
Some(container_candidate(
node,
sanitize_node_kind(node.kind()).as_str(),
source,
recurse_into(node, ChunkContext::ClassBody, &[], &["body"]),
))
},
_ => None,
}
}
fn classify_class<'t>(&self, node: Node<'t>, source: &str) -> Option<RawChunkCandidate<'t>> {
match node.kind() {
// Nested HCL block — only promote if it has an identifiable block name
"block" => extract_hcl_block_name(node, source).map(|name| {
make_container_chunk(
node,
format!("block_{name}"),
source,
recurse_into(node, ChunkContext::ClassBody, &[], &["body"]),
)
}),
// Nested Nix attrset values recurse into their binding_set just like top-level ones.
"attrset_expression" | "let_attrset_expression" | "rec_attrset_expression" => {
Some(make_container_chunk(
node,
sanitize_node_kind(node.kind()),
source,
recurse_nix_attrset(node),
))
},
// Nested Nix binding
"binding" => Some(classify_nix_binding(node, source)),
_ => None,
}
match node.kind() {
// Nested HCL block — only promote if it has an identifiable block name
"block" => extract_hcl_block_name(node, source).map(|name| {
make_container_chunk(
node,
format!("block_{name}"),
source,
recurse_into(node, ChunkContext::ClassBody, &[], &["body"]),
)
}),
// Nested Nix attrset values recurse into their binding_set just like top-level ones.
"attrset_expression" | "let_attrset_expression" | "rec_attrset_expression" => {
Some(make_container_chunk(
node,
sanitize_node_kind(node.kind()),
source,
recurse_nix_attrset(node),
))
},
// Nested Nix binding
"binding" => Some(classify_nix_binding(node, source)),
_ => None,
}
}
fn classify_function<'t>(&self, node: Node<'t>, source: &str) -> Option<RawChunkCandidate<'t>> {
+25 -19
View File
@@ -1384,31 +1384,37 @@ impl Config {
#[test]
fn nix_chunk_tree_exposes_attr_bindings() {
let source = r#"{
let source = r#"{
hello = "world";
nested = {
value = 1;
};
}
"#;
let tree = build_chunk_tree(source, "nix").expect("tree should build");
let attrset = tree
.chunks
.iter()
.find(|chunk| chunk.path == "attrset_expr")
.expect("attrset_expr chunk");
assert!(!tree.fallback, "nix should use tree-sitter chunking");
assert!(!attrset.leaf, "top-level attrset should recurse into bindings");
assert!(
attrset.children.iter().any(|child| child == "attrset_expr.attr_hello"),
"expected attr_hello child, got {:?}",
attrset.children
);
assert!(
attrset.children.iter().any(|child| child == "attrset_expr.attr_nested"),
"expected attr_nested child, got {:?}",
attrset.children
);
let tree = build_chunk_tree(source, "nix").expect("tree should build");
let attrset = tree
.chunks
.iter()
.find(|chunk| chunk.path == "attrset_expr")
.expect("attrset_expr chunk");
assert!(!tree.fallback, "nix should use tree-sitter chunking");
assert!(!attrset.leaf, "top-level attrset should recurse into bindings");
assert!(
attrset
.children
.iter()
.any(|child| child == "attrset_expr.attr_hello"),
"expected attr_hello child, got {:?}",
attrset.children
);
assert!(
attrset
.children
.iter()
.any(|child| child == "attrset_expr.attr_nested"),
"expected attr_nested child, got {:?}",
attrset.children
);
}
#[test]
+3
View File
@@ -40,6 +40,9 @@ pub mod image;
pub mod indent;
pub mod keys;
pub mod language;
pub mod power;
pub mod prof;
pub mod projfs_overlay;
pub mod ps;
+201
View File
@@ -0,0 +1,201 @@
//! macOS power assertions for preventing idle sleep.
//!
//! Exposes a small N-API handle that acquires a macOS `IOKit` power assertion
//! on construction and releases it on `stop()`/drop. On non-macOS platforms the
//! handle is a no-op so higher layers can use one code path.
use napi_derive::napi;
/// Options for starting a macOS power assertion.
#[napi(object, js_name = "MacOSPowerAssertionOptions")]
pub struct MacOSPowerAssertionOptions {
/// Human-readable reason shown in macOS power diagnostics.
pub reason: Option<String>,
/// Keep the display awake in addition to preventing idle system sleep.
pub display: Option<bool>,
}
#[cfg(target_os = "macos")]
mod platform {
use std::{
ffi::{CString, c_char, c_void},
ptr,
};
use napi::{Error, Result};
const UTF8_ENCODING: u32 = 0x0800_0100;
const ASSERTION_LEVEL_ON: u32 = 255;
const ASSERTION_ID_NONE: u32 = 0;
const PREVENT_USER_IDLE_SYSTEM_SLEEP: &str = "PreventUserIdleSystemSleep";
const PREVENT_USER_IDLE_DISPLAY_SLEEP: &str = "PreventUserIdleDisplaySleep";
type CFStringRef = *const c_void;
type CFTypeRef = *const c_void;
type IOPMAssertionID = u32;
type IOPMAssertionLevel = u32;
type IOReturn = i32;
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFStringCreateWithCString(
alloc: *const c_void,
c_str: *const c_char,
encoding: u32,
) -> CFStringRef;
fn CFRelease(value: CFTypeRef);
}
#[link(name = "IOKit", kind = "framework")]
unsafe extern "C" {
fn IOPMAssertionCreateWithName(
assertion_type: CFStringRef,
assertion_level: IOPMAssertionLevel,
assertion_name: CFStringRef,
assertion_id: *mut IOPMAssertionID,
) -> IOReturn;
fn IOPMAssertionRelease(assertion_id: IOPMAssertionID) -> IOReturn;
}
struct CfString(CFStringRef);
impl CfString {
fn new(value: &str) -> Result<Self> {
let c_string = CString::new(value).map_err(|_| {
Error::from_reason("Power assertion strings must not contain NUL bytes")
})?;
// SAFETY: `c_string` is a valid, NUL-terminated UTF-8 byte sequence for the
// duration of the call, and CoreFoundation copies the contents into a new
// `CFString` when creation succeeds.
let string_ref =
unsafe { CFStringCreateWithCString(ptr::null(), c_string.as_ptr(), UTF8_ENCODING) };
if string_ref.is_null() {
return Err(Error::from_reason(
"Failed to allocate CoreFoundation string for power assertion",
));
}
Ok(Self(string_ref))
}
const fn as_ptr(&self) -> CFStringRef {
self.0
}
}
impl Drop for CfString {
fn drop(&mut self) {
if self.0.is_null() {
return;
}
// SAFETY: `self.0` was returned by `CFStringCreateWithCString` in
// `CfString::new` and this wrapper owns the single outstanding reference, so
// releasing it here balances creation exactly once.
unsafe { CFRelease(self.0) };
}
}
pub struct AssertionInner {
assertion_id: IOPMAssertionID,
}
impl AssertionInner {
pub fn start(reason: &str, display: bool) -> Result<Self> {
let assertion_type = CfString::new(if display {
PREVENT_USER_IDLE_DISPLAY_SLEEP
} else {
PREVENT_USER_IDLE_SYSTEM_SLEEP
})?;
let assertion_reason = CfString::new(reason)?;
let mut assertion_id = ASSERTION_ID_NONE;
// SAFETY: both `CFStringRef` values are valid live CoreFoundation strings owned
// by this stack frame, `ASSERTION_LEVEL_ON` is the documented enabled value,
// and `assertion_id` points to writable storage for the returned identifier.
let status = unsafe {
IOPMAssertionCreateWithName(
assertion_type.as_ptr(),
ASSERTION_LEVEL_ON,
assertion_reason.as_ptr(),
&raw mut assertion_id,
)
};
if status != 0 {
return Err(Error::from_reason(format!(
"Failed to acquire macOS power assertion (IOReturn={status})"
)));
}
Ok(Self { assertion_id })
}
pub fn stop(&mut self) -> Result<()> {
if self.assertion_id == ASSERTION_ID_NONE {
return Ok(());
}
let assertion_id = self.assertion_id;
self.assertion_id = ASSERTION_ID_NONE;
// SAFETY: `assertion_id` came from a successful `IOPMAssertionCreateWithName`
// call owned by this handle, and we clear local ownership before releasing so
// the same assertion cannot be released twice.
let status = unsafe { IOPMAssertionRelease(assertion_id) };
if status != 0 {
return Err(Error::from_reason(format!(
"Failed to release macOS power assertion (IOReturn={status})"
)));
}
Ok(())
}
}
impl Drop for AssertionInner {
fn drop(&mut self) {
let _ = self.stop();
}
}
}
/// Long-lived macOS power assertion.
///
/// On macOS this acquires an `IOKit` assertion that prevents idle sleep until
/// the handle is stopped or dropped. On other platforms it is a no-op handle so
/// the caller can keep one cross-platform code path.
#[napi(js_name = "MacOSPowerAssertion")]
pub struct MacOSPowerAssertion {
#[cfg(target_os = "macos")]
inner: Option<platform::AssertionInner>,
}
#[napi]
impl MacOSPowerAssertion {
/// Acquire a macOS power assertion.
#[napi(factory)]
pub fn start(options: Option<MacOSPowerAssertionOptions>) -> napi::Result<Self> {
let reason = options
.as_ref()
.and_then(|value| value.reason.as_deref())
.filter(|value| !value.trim().is_empty())
.unwrap_or("Oh My Pi agent session");
let display = options
.as_ref()
.and_then(|value| value.display)
.unwrap_or(false);
#[cfg(target_os = "macos")]
{
Ok(Self { inner: Some(platform::AssertionInner::start(reason, display)?) })
}
#[cfg(not(target_os = "macos"))]
{
let _ = reason;
let _ = display;
Ok(Self {})
}
}
/// Release the power assertion early.
#[napi]
pub fn stop(&mut self) -> napi::Result<()> {
#[cfg(target_os = "macos")]
if let Some(inner) = &mut self.inner {
inner.stop()?;
}
Ok(())
}
}
+1
View File
@@ -22,6 +22,7 @@
### Changed
- Agent sessions now hold a macOS power assertion for their lifetime so the host stays awake during active coding-agent runs
- Updated chunk edit prompt documentation to use ellipsis (…) instead of ellipsis (...) for consistency in operation examples
- Modified chunk path parsing to preserve raw selector strings and extract CRC separately, enabling accurate chunk reference round-tripping in read/edit workflows
- Changed chunk edit behavior to auto-accept stale CRC checksums for subsequent operations on the same chunk within a batch, improving usability when applying multiple edits to the same target
@@ -49,7 +49,7 @@ import {
modelsAreEqual,
parseRateLimitReason,
} from "@oh-my-pi/pi-ai";
import type { SearchDb } from "@oh-my-pi/pi-natives";
import { MacOSPowerAssertion, type SearchDb } from "@oh-my-pi/pi-natives";
import { abortableSleep, getAgentDbPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
import type { AsyncJob, AsyncJobManager } from "../async";
import type { Rule } from "../capability/rule";
@@ -396,6 +396,9 @@ export class AgentSession {
readonly sessionManager: SessionManager;
readonly settings: Settings;
readonly searchDb: SearchDb | undefined;
#powerAssertion: MacOSPowerAssertion | undefined;
readonly configWarnings: string[] = [];
#asyncJobManager: AsyncJobManager | undefined = undefined;
@@ -507,11 +510,36 @@ export class AgentSession {
#promptGeneration = 0;
#providerSessionState = new Map<string, ProviderSessionState>();
#startPowerAssertion(): void {
if (process.platform !== "darwin") {
return;
}
try {
this.#powerAssertion = MacOSPowerAssertion.start({ reason: "Oh My Pi agent session" });
} catch (error) {
logger.warn("Failed to acquire macOS power assertion", { error: String(error) });
}
}
#stopPowerAssertion(): void {
const assertion = this.#powerAssertion;
this.#powerAssertion = undefined;
if (!assertion) {
return;
}
try {
assertion.stop();
} catch (error) {
logger.warn("Failed to release macOS power assertion", { error: String(error) });
}
}
constructor(config: AgentSessionConfig) {
this.agent = config.agent;
this.sessionManager = config.sessionManager;
this.settings = config.settings;
this.searchDb = config.searchDb;
this.#startPowerAssertion();
this.#asyncJobManager = config.asyncJobManager;
this.#scopedModels = config.scopedModels ?? [];
this.#thinkingLevel = config.thinkingLevel;
@@ -1673,6 +1701,7 @@ export class AgentSession {
if (drained === false && deliveryState) {
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
}
this.#stopPowerAssertion();
await this.sessionManager.close();
this.#closeAllProviderSessions("dispose");
this.#unsubscribePendingActionPush?.();
+1
View File
@@ -12,6 +12,7 @@
- Generated native module bindings in `native/index.js` and `native/index.d.ts` from napi-rs build output
- Added `gen-enums.ts` script to extract and export runtime enum values from TypeScript const enums
- Added `embedded-addon.js` for managing embedded native addon variants and metadata
- Added `MacOSPowerAssertion` for session-scoped macOS idle-sleep prevention without shelling out
### Changed
+22
View File
@@ -78,6 +78,20 @@ export declare class MacAppearanceObserver {
stop(): void
}
/**
* Long-lived macOS power assertion.
*
* On macOS this acquires an `IOKit` assertion that prevents idle sleep until
* the handle is stopped or dropped. On other platforms it is a no-op handle so
* the caller can keep one cross-platform code path.
*/
export declare class MacOSPowerAssertion {
/** Acquire a macOS power assertion. */
static start(options?: MacOSPowerAssertionOptions | undefined | null): MacOSPowerAssertion
/** Release the power assertion early. */
stop(): void
}
/** Image container for native interop. */
export declare class PhotonImage {
/**
@@ -943,6 +957,14 @@ export declare enum MacOSAppearance {
Light = 'light'
}
/** Options for starting a macOS power assertion. */
export interface MacOSPowerAssertionOptions {
/** Human-readable reason shown in macOS power diagnostics. */
reason?: string
/** Keep the display awake in addition to preventing idle system sleep. */
display?: boolean
}
/** A single match in the content. */
export interface Match {
/** 1-indexed line number. */
+2 -1
View File
@@ -177,7 +177,7 @@ function loadNative() {
const bindings = logger.time(`native:loadNative:require:${path.basename(candidate)}`, () =>
require_(candidate),
);
validateNative(bindings, candidate);
validateNative(bindings, candidate);
if (process.env.PI_DEV) {
console.log(`Loaded native addon from ${candidate}`);
}
@@ -265,6 +265,7 @@ function validateNative(bindings, source) {
checkFn("astEdit");
checkFn("detectMacOSAppearance");
checkFn("MacAppearanceObserver");
checkFn("MacOSPowerAssertion");
checkFn("projfsOverlayProbe");
checkFn("projfsOverlayStart");
checkFn("projfsOverlayStop");
+8
View File
@@ -12,6 +12,7 @@ import {
grep,
htmlToMarkdown,
invalidateFsScanCache,
MacOSPowerAssertion,
PtySession,
sanitizeText,
truncateToWidth,
@@ -476,5 +477,12 @@ describe("pi-natives", () => {
const input = "\x1b]0;title\x07hello";
expect(sanitizeText(input)).toBe("hello");
});
describe("MacOSPowerAssertion", () => {
it("should create a stoppable power assertion handle", () => {
const assertion = MacOSPowerAssertion.start({ reason: "pi-natives test" });
assertion.stop();
assertion.stop();
});
});
});
});