diff --git a/crates/pi-natives/src/chunk/ast_nix_hcl.rs b/crates/pi-natives/src/chunk/ast_nix_hcl.rs index 50c64b188..25f884f50 100644 --- a/crates/pi-natives/src/chunk/ast_nix_hcl.rs +++ b/crates/pi-natives/src/chunk/ast_nix_hcl.rs @@ -47,104 +47,103 @@ fn recurse_nix_attrset(node: Node<'_>) -> Option> { } fn recurse_nix_binding_value(node: Node<'_>) -> Option> { - 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> { - 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> { - 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> { diff --git a/crates/pi-natives/src/chunk/mod.rs b/crates/pi-natives/src/chunk/mod.rs index 0fe6a1b4d..3cb8137a5 100644 --- a/crates/pi-natives/src/chunk/mod.rs +++ b/crates/pi-natives/src/chunk/mod.rs @@ -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] diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 3d90d68f4..3f29b3536 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -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; diff --git a/crates/pi-natives/src/power.rs b/crates/pi-natives/src/power.rs new file mode 100644 index 000000000..59fd381e4 --- /dev/null +++ b/crates/pi-natives/src/power.rs @@ -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, + /// Keep the display awake in addition to preventing idle system sleep. + pub display: Option, +} + +#[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 { + 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 { + 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, +} + +#[napi] +impl MacOSPowerAssertion { + /// Acquire a macOS power assertion. + #[napi(factory)] + pub fn start(options: Option) -> napi::Result { + 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(()) + } +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ccb64e99c..411c4ed98 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index bdfbae4ac..570e8abe7 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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(); + #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?.(); diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 1ad3982b3..071007408 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -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 diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 98a7822dc..c062dfbe6 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -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. */ diff --git a/packages/natives/native/index.js b/packages/natives/native/index.js index 553d793a0..42bbb815b 100644 --- a/packages/natives/native/index.js +++ b/packages/natives/native/index.js @@ -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"); diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index ae509357a..05e3aed40 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -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(); + }); + }); }); });