harnery 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/bin/README.md +10 -0
- package/bin/agent-coord.mjs +3 -0
- package/bin/agent-hook.mjs +3 -0
- package/bin/harn.mjs +3 -0
- package/bin/run.mjs +43 -0
- package/dist/commander.d.ts +18 -6
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +74 -2
- package/dist/commands/agents.d.ts +25 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -27
- package/dist/commands/autoupdate.d.ts +12 -0
- package/dist/commands/autoupdate.d.ts.map +1 -0
- package/dist/commands/autoupdate.js +190 -0
- package/dist/commands/computer.d.ts +45 -0
- package/dist/commands/computer.d.ts.map +1 -0
- package/dist/commands/computer.js +509 -0
- package/dist/commands/docs.js +3 -1
- package/dist/commands/fetch.js +11 -1
- package/dist/commands/grep.d.ts +72 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +200 -47
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -9
- package/dist/commands/rm.js +2 -2
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +2 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +2 -3
- package/dist/core/agents/coord-bin.d.ts +3 -0
- package/dist/core/agents/coord-bin.d.ts.map +1 -1
- package/dist/core/agents/coord-bin.js +6 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +6 -2
- package/dist/core/agents/render/prompt-context.d.ts +55 -7
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +122 -48
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +36 -16
- package/dist/core/agents/rules/stop-hook.d.ts +6 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +20 -1
- package/dist/core/agents/state/proc-start.d.ts +3 -1
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +26 -4
- package/dist/core/autoupdate/engine.d.ts +97 -0
- package/dist/core/autoupdate/engine.d.ts.map +1 -0
- package/dist/core/autoupdate/engine.js +548 -0
- package/dist/core/autoupdate/fingerprint.d.ts +16 -0
- package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
- package/dist/core/autoupdate/fingerprint.js +73 -0
- package/dist/core/autoupdate/git.d.ts +30 -0
- package/dist/core/autoupdate/git.d.ts.map +1 -0
- package/dist/core/autoupdate/git.js +82 -0
- package/dist/core/autoupdate/index.d.ts +6 -0
- package/dist/core/autoupdate/index.d.ts.map +1 -0
- package/dist/core/autoupdate/index.js +4 -0
- package/dist/core/autoupdate/state.d.ts +59 -0
- package/dist/core/autoupdate/state.d.ts.map +1 -0
- package/dist/core/autoupdate/state.js +214 -0
- package/dist/core/autoupdate/types.d.ts +113 -0
- package/dist/core/autoupdate/types.d.ts.map +1 -0
- package/dist/core/autoupdate/types.js +35 -0
- package/dist/core/config.d.ts +53 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +114 -1
- package/dist/core/events/v3/bootstrap.js +1 -0
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +1 -0
- package/dist/core/hooks/adapter/wiring.d.ts +12 -2
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +38 -8
- package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
- package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
- package/dist/core/hooks/autoupdate-cue.js +53 -0
- package/dist/core/hooks/cli.js +52 -4
- package/dist/core/hooks/effects/index.js +4 -4
- package/dist/core/hooks/resolve/coord-root.js +1 -0
- package/dist/core/hooks/resolve/transcript.d.ts +12 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +54 -0
- package/dist/core/semantic/contract.d.ts +3 -3
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +4 -2
- package/dist/core/storage/modes.d.ts +1 -1
- package/dist/core/storage/modes.d.ts.map +1 -1
- package/dist/core/storage/modes.js +5 -1
- package/dist/core/workflow/durable-record.d.ts +2 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -1
- package/dist/core/workflow/durable-record.js +13 -1
- package/dist/core/workflow/engine.js +4 -0
- package/dist/core/workflow/spawn-claude.d.ts +7 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +16 -4
- package/dist/core/workflow/specialists.d.ts.map +1 -1
- package/dist/core/workflow/specialists.js +1 -0
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +1 -9
- package/dist/lib/computer/cua.d.ts +50 -0
- package/dist/lib/computer/cua.d.ts.map +1 -0
- package/dist/lib/computer/cua.js +474 -0
- package/dist/lib/computer/host.d.ts +19 -0
- package/dist/lib/computer/host.d.ts.map +1 -0
- package/dist/lib/computer/host.js +72 -0
- package/dist/lib/computer/lease.d.ts +13 -0
- package/dist/lib/computer/lease.d.ts.map +1 -0
- package/dist/lib/computer/lease.js +269 -0
- package/dist/lib/computer/types.d.ts +163 -0
- package/dist/lib/computer/types.d.ts.map +1 -0
- package/dist/lib/computer/types.js +10 -0
- package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
- package/dist/lib/docs-metadata-v2.js +6 -5
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +1 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -1
- package/dist/lib/guarded-remove.js +5 -4
- package/dist/lib/headless/index.d.ts +33 -0
- package/dist/lib/headless/index.d.ts.map +1 -1
- package/dist/lib/headless/index.js +38 -2
- package/dist/lib/http/client.d.ts +2 -9
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +30 -9
- package/dist/lib/instructions/apply.d.ts +9 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -1
- package/dist/lib/instructions/apply.js +92 -14
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
- package/dist/lib/instructions/git-hooks.js +8 -4
- package/dist/lib/instructions/templates.d.ts +11 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +81 -1
- package/package.json +5 -5
- package/schemas/config.schema.json +89 -1
- package/src/commander.ts +98 -8
- package/src/commands/agents.ts +100 -32
- package/src/commands/autoupdate.ts +250 -0
- package/src/commands/computer.ts +682 -0
- package/src/commands/docs.ts +3 -1
- package/src/commands/fetch.ts +11 -1
- package/src/commands/grep.ts +274 -59
- package/src/commands/init.ts +17 -7
- package/src/commands/rm.ts +2 -2
- package/src/commands/web.ts +2 -0
- package/src/core/agents/canonical-emit.ts +2 -3
- package/src/core/agents/coord-bin.ts +30 -0
- package/src/core/agents/coord-client.ts +6 -2
- package/src/core/agents/render/prompt-context.ts +152 -53
- package/src/core/agents/rules/claim-conflict.ts +50 -18
- package/src/core/agents/rules/stop-hook.ts +28 -1
- package/src/core/agents/state/proc-start.ts +29 -5
- package/src/core/autoupdate/engine.ts +691 -0
- package/src/core/autoupdate/fingerprint.ts +89 -0
- package/src/core/autoupdate/git.ts +120 -0
- package/src/core/autoupdate/index.ts +29 -0
- package/src/core/autoupdate/state.ts +269 -0
- package/src/core/autoupdate/types.ts +135 -0
- package/src/core/config.ts +142 -1
- package/src/core/events/v3/bootstrap.ts +1 -0
- package/src/core/events/v3/runtime-build.ts +1 -0
- package/src/core/hooks/adapter/wiring.ts +51 -6
- package/src/core/hooks/autoupdate-cue.ts +72 -0
- package/src/core/hooks/cli.ts +54 -1
- package/src/core/hooks/effects/index.ts +4 -4
- package/src/core/hooks/resolve/coord-root.ts +1 -0
- package/src/core/hooks/resolve/transcript.ts +61 -0
- package/src/core/semantic/service.ts +6 -2
- package/src/core/storage/modes.ts +7 -1
- package/src/core/workflow/durable-record.ts +15 -1
- package/src/core/workflow/engine.ts +3 -0
- package/src/core/workflow/spawn-claude.ts +16 -4
- package/src/core/workflow/specialists.ts +1 -0
- package/src/core/workflow/types.ts +10 -0
- package/src/core/workflow/workspaces/leases.ts +1 -9
- package/src/lib/computer/cua.ts +572 -0
- package/src/lib/computer/host.ts +87 -0
- package/src/lib/computer/lease.ts +299 -0
- package/src/lib/computer/types.ts +177 -0
- package/src/lib/docs-metadata-v2.ts +24 -5
- package/src/lib/exec.ts +1 -0
- package/src/lib/guarded-remove.ts +5 -3
- package/src/lib/headless/index.ts +66 -2
- package/src/lib/http/client.ts +33 -19
- package/src/lib/instructions/apply.ts +100 -12
- package/src/lib/instructions/git-hooks.ts +8 -4
- package/src/lib/instructions/templates.ts +95 -1
package/src/core/config.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* `coord` (heartbeat freshness), `logs` (structured-log storage budgets),
|
|
12
12
|
* `artifacts` (working-file retention),
|
|
13
13
|
* `backup` (restic repo/password/prune policy), `sync` (rclone
|
|
14
|
-
* remote/prefix),
|
|
14
|
+
* remote/prefix), `computer` (opt-in desktop control), `autoUpdate` (activity-fired
|
|
15
|
+
* project self-update), and `web` (dashboard port) sections. The `files` deny/override section
|
|
15
16
|
* is parsed separately by `web/lib/files.ts`.
|
|
16
17
|
*
|
|
17
18
|
* Env vars and CLI flags override any config value per invocation (each accessor
|
|
@@ -24,6 +25,7 @@ import { homedir } from "node:os";
|
|
|
24
25
|
import { isAbsolute, join, resolve } from "node:path";
|
|
25
26
|
import { coordEnv } from "../lib/env.ts";
|
|
26
27
|
import { DEFAULT_WEB_PORT } from "../lib/local-file-url.ts";
|
|
28
|
+
import { AUTOUPDATE_DEFAULTS, type AutoUpdateConfig } from "./autoupdate/types.ts";
|
|
27
29
|
import { resolveEventLedgerRotateActiveBytesV3 } from "./events/v3/rotation-config.ts";
|
|
28
30
|
import {
|
|
29
31
|
type PromptContextConfig,
|
|
@@ -82,6 +84,8 @@ interface HarneryConfig {
|
|
|
82
84
|
promptContext?: unknown;
|
|
83
85
|
/** Project-only opt-in; read via `codexWindowsBridgeConfig()`. */
|
|
84
86
|
codexWindowsBridge?: unknown;
|
|
87
|
+
/** Project-only opt-in; read via `portableHookCommands()`. */
|
|
88
|
+
portableCommands?: unknown;
|
|
85
89
|
};
|
|
86
90
|
/**
|
|
87
91
|
* Agent-ritual policy owned by the host project. Git finalization is opt-in:
|
|
@@ -220,6 +224,24 @@ interface HarneryConfig {
|
|
|
220
224
|
sync?: { remote?: string; prefix?: string };
|
|
221
225
|
/** Standalone dashboard defaults. */
|
|
222
226
|
web?: { port?: number; bind?: string };
|
|
227
|
+
/**
|
|
228
|
+
* Opt-in desktop control (`computer` command, ADR 0228). Inert unless
|
|
229
|
+
* `enabled` is true. Project config only; read via `readComputerConfig()`.
|
|
230
|
+
*/
|
|
231
|
+
computer?: { enabled?: boolean; idle_seconds?: number; wait_seconds?: number };
|
|
232
|
+
/**
|
|
233
|
+
* Activity-fired project self-update (ADR 0229). Inert unless `enabled` is
|
|
234
|
+
* true. Project config only; read via `readAutoUpdateConfig()`.
|
|
235
|
+
*/
|
|
236
|
+
autoUpdate?: {
|
|
237
|
+
enabled?: boolean;
|
|
238
|
+
checkMinutes?: number;
|
|
239
|
+
steps?: unknown;
|
|
240
|
+
dailySteps?: unknown;
|
|
241
|
+
watch?: unknown;
|
|
242
|
+
timeoutMinutes?: number;
|
|
243
|
+
cleanup?: boolean;
|
|
244
|
+
};
|
|
223
245
|
[k: string]: unknown;
|
|
224
246
|
}
|
|
225
247
|
|
|
@@ -545,6 +567,18 @@ export function codexWindowsBridgeConfig(projectRoot: string): CodexWindowsBridg
|
|
|
545
567
|
return { entryPoint, error: null };
|
|
546
568
|
}
|
|
547
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Per-project opt-in that makes `init` write the same adapter hook commands on
|
|
572
|
+
* every OS: `agent-hook` from PATH, plus a native Codex `commandWindows`. A
|
|
573
|
+
* project that commits its settings files and is cloned on Windows and POSIX
|
|
574
|
+
* needs it, because the default form depends on the OS that ran `init`. Read
|
|
575
|
+
* from the project config only, for the same reason as the Windows bridge.
|
|
576
|
+
* Anything but `true` keeps the default.
|
|
577
|
+
*/
|
|
578
|
+
export function portableHookCommands(projectRoot: string): boolean {
|
|
579
|
+
return readProjectConfig(projectRoot).hooks?.portableCommands === true;
|
|
580
|
+
}
|
|
581
|
+
|
|
548
582
|
export function hostPromptReminder(coordRoot?: string | null): string | null {
|
|
549
583
|
const root = coordRoot ?? findCoordRoot();
|
|
550
584
|
if (!root) return null;
|
|
@@ -718,6 +752,113 @@ export function readAdmissionConfig(coordRoot?: string | null): AdmissionProject
|
|
|
718
752
|
};
|
|
719
753
|
}
|
|
720
754
|
|
|
755
|
+
/** Effective `computer` settings; unset tunables fall back to the command's defaults. */
|
|
756
|
+
export interface ComputerProjectConfig {
|
|
757
|
+
enabled: boolean;
|
|
758
|
+
/** Desktop lease idle expiry in seconds. */
|
|
759
|
+
idle_seconds?: number;
|
|
760
|
+
/** How long `computer call` waits for another agent's lease, in seconds. */
|
|
761
|
+
wait_seconds?: number;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Desktop-control settings from `.harnery/config.jsonc`:
|
|
766
|
+
*
|
|
767
|
+
* ```jsonc
|
|
768
|
+
* "computer": { "enabled": true, "idle_seconds": 120, "wait_seconds": 30 }
|
|
769
|
+
* ```
|
|
770
|
+
*
|
|
771
|
+
* Project config only: enabling desktop control is a per-project decision, so a
|
|
772
|
+
* user-global value never turns it on. Malformed values are dropped one by one;
|
|
773
|
+
* `enabled` is true only when the project sets it to exactly `true`.
|
|
774
|
+
*/
|
|
775
|
+
export function readComputerConfig(coordRoot?: string | null): ComputerProjectConfig {
|
|
776
|
+
const root = coordRoot ?? findCoordRoot();
|
|
777
|
+
if (!root) return { enabled: false };
|
|
778
|
+
const raw = readProjectConfig(root).computer;
|
|
779
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { enabled: false };
|
|
780
|
+
const record = raw as Record<string, unknown>;
|
|
781
|
+
const idle = positiveNumber(record.idle_seconds, 86_400);
|
|
782
|
+
const wait = record.wait_seconds;
|
|
783
|
+
const waitOk = typeof wait === "number" && Number.isFinite(wait) && wait >= 0 && wait <= 3600;
|
|
784
|
+
return {
|
|
785
|
+
enabled: record.enabled === true,
|
|
786
|
+
...(idle !== undefined ? { idle_seconds: idle } : {}),
|
|
787
|
+
...(waitOk ? { wait_seconds: wait } : {}),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Project-only parse cache keyed by the file's stat signature. Hooks call the
|
|
792
|
+
// auto-update reader on every prompt, so an unchanged file costs one stat.
|
|
793
|
+
let projectCache: { path: string; signature: string; cfg: HarneryConfig } | null = null;
|
|
794
|
+
|
|
795
|
+
function readProjectConfigCached(root: string): HarneryConfig {
|
|
796
|
+
const p = join(root, ".harnery", "config.jsonc");
|
|
797
|
+
const signature = statSignature(p);
|
|
798
|
+
if (signature === null) return {};
|
|
799
|
+
if (projectCache && projectCache.path === p && projectCache.signature === signature) {
|
|
800
|
+
return projectCache.cfg;
|
|
801
|
+
}
|
|
802
|
+
const cfg = parseConfigFile(p);
|
|
803
|
+
projectCache = { path: p, signature, cfg };
|
|
804
|
+
return cfg;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function clampInteger(value: unknown, min: number, max: number, fallback: number): number {
|
|
808
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
809
|
+
return Math.min(max, Math.max(min, Math.round(value)));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Keep each argv that is a non-empty array of non-empty strings; drop the rest. */
|
|
813
|
+
function argvList(value: unknown): string[][] {
|
|
814
|
+
if (!Array.isArray(value)) return [];
|
|
815
|
+
const out: string[][] = [];
|
|
816
|
+
for (const entry of value) {
|
|
817
|
+
if (!Array.isArray(entry) || entry.length === 0) continue;
|
|
818
|
+
if (!entry.every((part) => typeof part === "string" && part.length > 0)) continue;
|
|
819
|
+
out.push([...(entry as string[])]);
|
|
820
|
+
}
|
|
821
|
+
return out;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Project self-update settings from `.harnery/config.jsonc` (ADR 0229):
|
|
826
|
+
*
|
|
827
|
+
* ```jsonc
|
|
828
|
+
* "autoUpdate": {
|
|
829
|
+
* "enabled": true,
|
|
830
|
+
* "steps": [["bin/pull-all"]],
|
|
831
|
+
* "dailySteps": [["bin/doctor", "--quiet"]],
|
|
832
|
+
* "watch": ["~/.local/share/example/hooks.json"],
|
|
833
|
+
* "checkMinutes": 15,
|
|
834
|
+
* "timeoutMinutes": 20,
|
|
835
|
+
* "cleanup": true
|
|
836
|
+
* }
|
|
837
|
+
* ```
|
|
838
|
+
*
|
|
839
|
+
* Project config only: running commands on activity is a per-project
|
|
840
|
+
* decision, so a user-global value never turns it on. Null when the project
|
|
841
|
+
* has no `autoUpdate` section. Malformed values are dropped one by one;
|
|
842
|
+
* `enabled` is true only when the project sets it to exactly `true`.
|
|
843
|
+
*/
|
|
844
|
+
export function readAutoUpdateConfig(coordRoot: string): AutoUpdateConfig | null {
|
|
845
|
+
const raw = readProjectConfigCached(coordRoot).autoUpdate;
|
|
846
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
847
|
+
const record = raw as Record<string, unknown>;
|
|
848
|
+
const watch = Array.isArray(record.watch)
|
|
849
|
+
? record.watch.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
|
|
850
|
+
: [];
|
|
851
|
+
return {
|
|
852
|
+
enabled: record.enabled === true,
|
|
853
|
+
checkMinutes: clampInteger(record.checkMinutes, 1, 1440, AUTOUPDATE_DEFAULTS.checkMinutes),
|
|
854
|
+
steps: argvList(record.steps),
|
|
855
|
+
dailySteps: argvList(record.dailySteps),
|
|
856
|
+
watch,
|
|
857
|
+
timeoutMinutes: clampInteger(record.timeoutMinutes, 1, 240, AUTOUPDATE_DEFAULTS.timeoutMinutes),
|
|
858
|
+
cleanup: record.cleanup !== false,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
721
862
|
/**
|
|
722
863
|
* Whether this repo pins workflow runs to subscription billing:
|
|
723
864
|
* `.harnery/config.jsonc` `{ "workflow": { "subscriptionOnly": true } }`.
|
|
@@ -16,6 +16,7 @@ export function currentHarneryRuntimeBuild(): string {
|
|
|
16
16
|
const root = harneryPackageRoot();
|
|
17
17
|
if (existsSync(resolve(root, ".git"))) {
|
|
18
18
|
const result = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
19
|
+
windowsHide: true,
|
|
19
20
|
encoding: "utf8",
|
|
20
21
|
timeout: 5_000,
|
|
21
22
|
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { codexWindowsBridgeConfig, resolveBinName } from "../../config.ts";
|
|
17
|
+
import { codexWindowsBridgeConfig, portableHookCommands, resolveBinName } from "../../config.ts";
|
|
18
18
|
import {
|
|
19
19
|
ADAPTER_SPECS,
|
|
20
20
|
type AdapterId,
|
|
@@ -122,6 +122,22 @@ export function hookCommandWindows(
|
|
|
122
122
|
export interface WiringOptions {
|
|
123
123
|
/** Bridge entry point from `hooks.codexWindowsBridge`, or null when not opted in. */
|
|
124
124
|
windowsBridgeEntryPoint?: string | null;
|
|
125
|
+
/**
|
|
126
|
+
* `hooks.portableCommands`: write the same `agent-hook` commands from PATH on
|
|
127
|
+
* every OS, plus a native Codex `commandWindows`, so one committed settings
|
|
128
|
+
* file is current everywhere.
|
|
129
|
+
*/
|
|
130
|
+
portableCommands?: boolean;
|
|
131
|
+
/** The OS to write for. Defaults to the host; tests inject it. */
|
|
132
|
+
platform?: NodeJS.Platform;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The wiring options a project's committed config selects. */
|
|
136
|
+
export function projectWiringOptions(projectRoot: string): WiringOptions {
|
|
137
|
+
return {
|
|
138
|
+
windowsBridgeEntryPoint: codexWindowsBridgeConfig(projectRoot).entryPoint,
|
|
139
|
+
portableCommands: portableHookCommands(projectRoot),
|
|
140
|
+
};
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
/** The `commandWindows` init writes for one event, or undefined for none. */
|
|
@@ -132,7 +148,12 @@ export function expectedCommandWindows(
|
|
|
132
148
|
adapter: AdapterId,
|
|
133
149
|
options?: WiringOptions,
|
|
134
150
|
): string | undefined {
|
|
135
|
-
if (!spec.windowsBridgeHooks
|
|
151
|
+
if (!spec.windowsBridgeHooks) return undefined;
|
|
152
|
+
if (!options?.windowsBridgeEntryPoint) {
|
|
153
|
+
return (options?.platform ?? process.platform) === "win32" || options?.portableCommands
|
|
154
|
+
? nativeHookCommand(agentHookPath, subcommand, adapter)
|
|
155
|
+
: undefined;
|
|
156
|
+
}
|
|
136
157
|
return hookCommandWindows(
|
|
137
158
|
spec,
|
|
138
159
|
agentHookPath,
|
|
@@ -193,7 +214,11 @@ export function hookCommand(
|
|
|
193
214
|
agentHookPath: string,
|
|
194
215
|
subcommand: string,
|
|
195
216
|
adapter: AdapterId,
|
|
217
|
+
options?: WiringOptions,
|
|
196
218
|
): string {
|
|
219
|
+
if ((options?.platform ?? process.platform) === "win32") {
|
|
220
|
+
return nativeHookCommand(agentHookPath, subcommand, adapter);
|
|
221
|
+
}
|
|
197
222
|
if (agentHookPath === "agent-hook") {
|
|
198
223
|
return `agent-hook ${subcommand} --adapter ${adapter}`;
|
|
199
224
|
}
|
|
@@ -201,13 +226,29 @@ export function hookCommand(
|
|
|
201
226
|
return `bash ${anchor}${agentHookPath} ${subcommand} --adapter ${adapter}`;
|
|
202
227
|
}
|
|
203
228
|
|
|
229
|
+
function nativeHookCommand(agentHookPath: string, subcommand: string, adapter: AdapterId): string {
|
|
230
|
+
if (agentHookPath === "agent-hook") return `agent-hook ${subcommand} --adapter ${adapter}`;
|
|
231
|
+
const runner = join(dirname(agentHookPath), "run.mjs").replaceAll("\\", "/");
|
|
232
|
+
return `node "${runner}" agent-hook ${subcommand} --adapter ${adapter}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
204
235
|
/**
|
|
205
236
|
* Pick a committed hook target that survives cloning the consumer elsewhere.
|
|
206
237
|
* Projects that contain Harnery can use its repo-relative launcher. Standalone
|
|
207
238
|
* consumers use the package's installed `agent-hook` executable from PATH
|
|
208
239
|
* instead of recording a relative path back into one developer's package tree.
|
|
209
240
|
*/
|
|
210
|
-
export function agentHookPathForProject(
|
|
241
|
+
export function agentHookPathForProject(
|
|
242
|
+
projectRoot: string,
|
|
243
|
+
packageRoot: string,
|
|
244
|
+
options?: WiringOptions,
|
|
245
|
+
): string {
|
|
246
|
+
// Native npm shims run the linked source checkout without Bash, and do not
|
|
247
|
+
// depend on a hook's working directory after an agent changes directories.
|
|
248
|
+
// A project that commits one settings file for every OS asks for the same.
|
|
249
|
+
if ((options?.platform ?? process.platform) === "win32" || options?.portableCommands) {
|
|
250
|
+
return "agent-hook";
|
|
251
|
+
}
|
|
211
252
|
const hook = join(packageRoot, "bin", "agent-hook");
|
|
212
253
|
const candidate = relative(projectRoot, hook);
|
|
213
254
|
if (candidate === ".." || candidate.startsWith(`..${sep}`) || isAbsolute(candidate)) {
|
|
@@ -268,6 +309,7 @@ export function diffWiring(
|
|
|
268
309
|
expected.agentHookPath,
|
|
269
310
|
event.subcommand,
|
|
270
311
|
expected.adapter,
|
|
312
|
+
expected,
|
|
271
313
|
);
|
|
272
314
|
const timeout = hookTimeoutFor(spec, event.subcommand);
|
|
273
315
|
const commandWindows = expectedCommandWindows(
|
|
@@ -386,7 +428,9 @@ function pluginDrift(
|
|
|
386
428
|
const packageRoot = harneryPackageRoot();
|
|
387
429
|
const check = checkOpenCodePlugin(projectRoot, {
|
|
388
430
|
binName: resolveBinName(projectRoot),
|
|
389
|
-
agentHook: packageRoot
|
|
431
|
+
agentHook: packageRoot
|
|
432
|
+
? agentHookPathForProject(projectRoot, packageRoot, projectWiringOptions(projectRoot))
|
|
433
|
+
: "agent-hook",
|
|
390
434
|
});
|
|
391
435
|
if (check.status === "fresh") return null;
|
|
392
436
|
if (check.status === "missing") return null;
|
|
@@ -425,14 +469,15 @@ export function loadAdapterWiring(projectRoot: string): AdapterWiringStatus[] {
|
|
|
425
469
|
continue;
|
|
426
470
|
}
|
|
427
471
|
const packageRoot = harneryPackageRoot();
|
|
472
|
+
const options = projectWiringOptions(projectRoot);
|
|
428
473
|
const diff = diffWiring(
|
|
429
474
|
settings,
|
|
430
475
|
spec,
|
|
431
476
|
packageRoot
|
|
432
477
|
? {
|
|
433
|
-
agentHookPath: agentHookPathForProject(projectRoot, packageRoot),
|
|
478
|
+
agentHookPath: agentHookPathForProject(projectRoot, packageRoot, options),
|
|
434
479
|
adapter: id,
|
|
435
|
-
|
|
480
|
+
...options,
|
|
436
481
|
}
|
|
437
482
|
: undefined,
|
|
438
483
|
);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { coordEnv } from "../../lib/env.ts";
|
|
2
|
+
import type { AutoUpdateNotice, TickResult } from "../autoupdate/types.ts";
|
|
3
|
+
import { readAutoUpdateConfig } from "../config.ts";
|
|
4
|
+
|
|
5
|
+
/** Prefix on every self-update line injected into an agent's context. */
|
|
6
|
+
export const AUTOUPDATE_CUE_PREFIX = "Auto-update: ";
|
|
7
|
+
|
|
8
|
+
export interface AutoUpdateCueDeps {
|
|
9
|
+
tick: (coordRoot: string) => TickResult;
|
|
10
|
+
pendingNotices: (coordRoot: string, sessionKey: string) => AutoUpdateNotice[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AutoUpdateCueOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Whether this hook will show context to the agent. When false, the tick
|
|
16
|
+
* still runs but notices stay queued for a session that can read them.
|
|
17
|
+
*/
|
|
18
|
+
deliver?: boolean;
|
|
19
|
+
/** Test seam; production loads the engine lazily. */
|
|
20
|
+
deps?: AutoUpdateCueDeps;
|
|
21
|
+
/** Receives tick or delivery failures. The cue itself never throws. */
|
|
22
|
+
onError?: (err: unknown, phase: "autoupdate-tick" | "autoupdate-notices") => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function loadDeps(): Promise<AutoUpdateCueDeps> {
|
|
26
|
+
const engine = await import("../autoupdate/index.ts");
|
|
27
|
+
return {
|
|
28
|
+
tick: (root) => engine.autoUpdateTick(root),
|
|
29
|
+
pendingNotices: (root, key) => engine.pendingNotices(root, key),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Fire the activity-driven self-update check (ADR 0229) and collect the
|
|
35
|
+
* notices this session has not seen yet, one context line each.
|
|
36
|
+
*
|
|
37
|
+
* Projects without an `autoUpdate` section pay one config stat and never load
|
|
38
|
+
* the engine. With the section, the tick's hot path is one stamp stat; any
|
|
39
|
+
* real work runs in a detached child, so the hook never waits on it. Fails
|
|
40
|
+
* open: an error is reported through `onError` and the cue is empty.
|
|
41
|
+
*/
|
|
42
|
+
export async function autoUpdateHookContext(
|
|
43
|
+
coordRoot: string,
|
|
44
|
+
sessionKey: string,
|
|
45
|
+
options: AutoUpdateCueOptions = {},
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
if (coordEnv("AUTOUPDATE_CHILD") === "1") return "";
|
|
48
|
+
let deps: AutoUpdateCueDeps;
|
|
49
|
+
try {
|
|
50
|
+
if (!readAutoUpdateConfig(coordRoot)) return "";
|
|
51
|
+
deps = options.deps ?? (await loadDeps());
|
|
52
|
+
} catch (err) {
|
|
53
|
+
options.onError?.(err, "autoupdate-tick");
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
deps.tick(coordRoot);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
options.onError?.(err, "autoupdate-tick");
|
|
60
|
+
}
|
|
61
|
+
if (options.deliver === false || !sessionKey) return "";
|
|
62
|
+
try {
|
|
63
|
+
return deps
|
|
64
|
+
.pendingNotices(coordRoot, sessionKey)
|
|
65
|
+
.map((notice) => `${AUTOUPDATE_CUE_PREFIX}${notice.text.trim()}`)
|
|
66
|
+
.filter((line) => line.length > AUTOUPDATE_CUE_PREFIX.length)
|
|
67
|
+
.join("\n");
|
|
68
|
+
} catch (err) {
|
|
69
|
+
options.onError?.(err, "autoupdate-notices");
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/core/hooks/cli.ts
CHANGED
|
@@ -108,6 +108,7 @@ import {
|
|
|
108
108
|
parsePayload,
|
|
109
109
|
} from "./adapter/parse.ts";
|
|
110
110
|
import { hookTimeoutFor } from "./adapter/wiring.ts";
|
|
111
|
+
import { autoUpdateHookContext } from "./autoupdate-cue.ts";
|
|
111
112
|
import { scheduleBackupSnapshot } from "./backup-schedule.ts";
|
|
112
113
|
import {
|
|
113
114
|
captureImages,
|
|
@@ -139,6 +140,7 @@ import type { SessionNameDisplayInspection } from "./resolve/transcript.ts";
|
|
|
139
140
|
import {
|
|
140
141
|
inspectSessionNameDisplayImmediately,
|
|
141
142
|
scanAssistantStatusBoxPresent,
|
|
143
|
+
scanPendingBackgroundHandoffs,
|
|
142
144
|
scanStatusBoxPresent,
|
|
143
145
|
scanTranscriptRuntime,
|
|
144
146
|
} from "./resolve/transcript.ts";
|
|
@@ -1315,6 +1317,13 @@ async function handleSessionStarted(run: HookRun): Promise<void> {
|
|
|
1315
1317
|
} catch (err) {
|
|
1316
1318
|
logError(coordRoot, err, { phase: "backup-schedule" });
|
|
1317
1319
|
}
|
|
1320
|
+
// Project self-update (ADR 0229) fires on activity. The tick costs one stat
|
|
1321
|
+
// when fresh and otherwise launches a detached child; queued notices for
|
|
1322
|
+
// this session ride the same context as the backup cue.
|
|
1323
|
+
const autoUpdateCue = await autoUpdateHookContext(coordRoot, owner.instance_id, {
|
|
1324
|
+
deliver: coordEnv("WORKFLOW_CHILD") !== "1",
|
|
1325
|
+
onError: (err, phase) => logError(coordRoot, err, { phase }),
|
|
1326
|
+
});
|
|
1318
1327
|
// Storage maintenance can walk large stores. It runs in a detached worker so
|
|
1319
1328
|
// session start emits its context without waiting for it; an adapter that
|
|
1320
1329
|
// stops a slow hook would otherwise drop that context entirely.
|
|
@@ -1343,7 +1352,7 @@ async function handleSessionStarted(run: HookRun): Promise<void> {
|
|
|
1343
1352
|
data,
|
|
1344
1353
|
adapter,
|
|
1345
1354
|
recovery?.briefing ?? "",
|
|
1346
|
-
backupCue,
|
|
1355
|
+
[backupCue, autoUpdateCue].filter(Boolean).join("\n\n"),
|
|
1347
1356
|
);
|
|
1348
1357
|
if (injected && recovery) {
|
|
1349
1358
|
completeRecoveryInjection(coordRoot, owner.instance_id, sessionId, recovery);
|
|
@@ -1472,6 +1481,11 @@ async function handleTurnStarted(run: HookRun): Promise<void> {
|
|
|
1472
1481
|
logError(coordRoot, err, { phase: "prompt-context-user-prompt" });
|
|
1473
1482
|
}
|
|
1474
1483
|
}
|
|
1484
|
+
// Project self-update (ADR 0229): see handleSessionStarted.
|
|
1485
|
+
const autoUpdateCue = await autoUpdateHookContext(coordRoot, owner.instance_id, {
|
|
1486
|
+
deliver: coordEnv("WORKFLOW_CHILD") !== "1",
|
|
1487
|
+
onError: (err, phase) => logError(coordRoot, err, { phase }),
|
|
1488
|
+
});
|
|
1475
1489
|
try {
|
|
1476
1490
|
const injected = await emitUserPromptSubmitSystemMessage(
|
|
1477
1491
|
coordRoot,
|
|
@@ -1481,6 +1495,7 @@ async function handleTurnStarted(run: HookRun): Promise<void> {
|
|
|
1481
1495
|
payload?.cwd,
|
|
1482
1496
|
recovery?.briefing ?? "",
|
|
1483
1497
|
hostPromptContext,
|
|
1498
|
+
autoUpdateCue,
|
|
1484
1499
|
);
|
|
1485
1500
|
if (injected && recovery) {
|
|
1486
1501
|
completeRecoveryInjection(coordRoot, owner.instance_id, sessionId, recovery);
|
|
@@ -1506,6 +1521,18 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
|
|
|
1506
1521
|
logError(coordRoot, err, { phase: "stop-presence" });
|
|
1507
1522
|
}
|
|
1508
1523
|
|
|
1524
|
+
// Claude Code may move a running foreground command (often the agent's own
|
|
1525
|
+
// commit and push) into the background to deliver a user message. Until it
|
|
1526
|
+
// reports back, finalization cannot pass, so the verdict defers the status
|
|
1527
|
+
// demands instead of bouncing the agent in a loop.
|
|
1528
|
+
const pendingBackgroundHandoffs =
|
|
1529
|
+
adapter === "claude-code"
|
|
1530
|
+
? scanPendingBackgroundHandoffs(
|
|
1531
|
+
payload?.transcript_path ??
|
|
1532
|
+
behavior.discoverTranscript?.(payload?.session_id ?? sessionId),
|
|
1533
|
+
)
|
|
1534
|
+
: [];
|
|
1535
|
+
|
|
1509
1536
|
// Stop verdict (V3 ritual + task/status gate). Direct in-process call: the
|
|
1510
1537
|
// rule lives in Harnery. agent-hook already emitted this turn.completed
|
|
1511
1538
|
// with privacy-safe ritual observations, so the evidence is in the stream.
|
|
@@ -1519,8 +1546,24 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
|
|
|
1519
1546
|
session_name_observation_unavailable: typeof data.session_name_unavailable_reason === "string",
|
|
1520
1547
|
bypass: coordEnv("AGENT_COORD_BYPASS_STOP") === "1",
|
|
1521
1548
|
workflow_child: coordEnv("WORKFLOW_CHILD") === "1",
|
|
1549
|
+
pending_background_handoff: pendingBackgroundHandoffs.length > 0,
|
|
1522
1550
|
});
|
|
1551
|
+
if (verdict.rule === "stop-hook.background_handoff_deferred") {
|
|
1552
|
+
appendDebug(coordRoot, {
|
|
1553
|
+
...debugBase,
|
|
1554
|
+
effect: "stop-deferred-background-handoff",
|
|
1555
|
+
background_task_ids: pendingBackgroundHandoffs,
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1523
1558
|
if (!verdict.allow) {
|
|
1559
|
+
// Re-arm the prompt-time ritual reminder; it is otherwise shown only on a
|
|
1560
|
+
// session's first prompt so the per-prompt context stays small.
|
|
1561
|
+
try {
|
|
1562
|
+
const { markTurnRitualBounced } = await import("../agents/render/prompt-context.ts");
|
|
1563
|
+
markTurnRitualBounced(coordRoot, owner.instance_id);
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
logError(coordRoot, err, { phase: "stop-ritual-rearm" });
|
|
1566
|
+
}
|
|
1524
1567
|
// Backstop for sessions whose evidence can never land (e.g. a headless
|
|
1525
1568
|
// child whose status command resolves to a different owner): after
|
|
1526
1569
|
// `cap` consecutive blocked stops in one cycle, allow the stop instead
|
|
@@ -1553,6 +1596,12 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
|
|
|
1553
1596
|
// until the adapter has committed turn.completed above. Reconcile only
|
|
1554
1597
|
// when such a request exists; any later real work cancels it in the
|
|
1555
1598
|
// finalizer instead of being terminated underneath the agent.
|
|
1599
|
+
// A deferred stop proves nothing about finalization; an explicit end must
|
|
1600
|
+
// wait for the turn that reports the background task's result.
|
|
1601
|
+
if (verdict.rule === "stop-hook.background_handoff_deferred") {
|
|
1602
|
+
if (behavior.runtimeContextRetry) scheduleRuntimeContextRetry(coordRoot, adapter, sessionId);
|
|
1603
|
+
return undefined;
|
|
1604
|
+
}
|
|
1556
1605
|
try {
|
|
1557
1606
|
const { hasPendingExplicitSessionEndV3, reconcileSessionFinalizationV3 } = await import(
|
|
1558
1607
|
"../agents/session-finalizer-v3.ts"
|
|
@@ -2408,6 +2457,7 @@ async function emitUserPromptSubmitSystemMessage(
|
|
|
2408
2457
|
workspaceCwd?: string,
|
|
2409
2458
|
recoveryBriefing = "",
|
|
2410
2459
|
hostPromptContext = "",
|
|
2460
|
+
autoUpdateCue = "",
|
|
2411
2461
|
): Promise<boolean> {
|
|
2412
2462
|
// Workflow children get no prompt-time coordination context, matching
|
|
2413
2463
|
// SessionStart. The turn ritual is not enforced for them, the naming nudge
|
|
@@ -2436,6 +2486,9 @@ async function emitUserPromptSubmitSystemMessage(
|
|
|
2436
2486
|
if (hostPromptContext) {
|
|
2437
2487
|
additionalContext = [additionalContext, hostPromptContext].filter(Boolean).join("\n\n");
|
|
2438
2488
|
}
|
|
2489
|
+
if (autoUpdateCue) {
|
|
2490
|
+
additionalContext = [additionalContext, autoUpdateCue].filter(Boolean).join("\n\n");
|
|
2491
|
+
}
|
|
2439
2492
|
if (!additionalContext) return false;
|
|
2440
2493
|
|
|
2441
2494
|
const { emitContext } = await import("./adapter/output.ts");
|
|
@@ -17,7 +17,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { applyDetection } from "../../../lib/presence.ts";
|
|
20
|
-
import { coordBinPath } from "../../agents/coord-bin.ts";
|
|
20
|
+
import { coordBinPath, spawnCoordBinSync } from "../../agents/coord-bin.ts";
|
|
21
21
|
|
|
22
22
|
export { captureImages, imageJanitor, recordImageArtifactsV3 } from "./image-capture.ts";
|
|
23
23
|
|
|
@@ -70,7 +70,7 @@ export function journalJanitor(repoRoot: string): void {
|
|
|
70
70
|
try {
|
|
71
71
|
const bin = harnBin(repoRoot);
|
|
72
72
|
if (!bin) return;
|
|
73
|
-
|
|
73
|
+
spawnCoordBinSync(bin, ["journal", "janitor", "--quiet"], {
|
|
74
74
|
env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
|
|
75
75
|
timeout: 5000,
|
|
76
76
|
stdio: "ignore",
|
|
@@ -89,7 +89,7 @@ export function journalRecoveryCue(repoRoot: string): string {
|
|
|
89
89
|
try {
|
|
90
90
|
const bin = harnBin(repoRoot);
|
|
91
91
|
if (!bin) return "";
|
|
92
|
-
const r =
|
|
92
|
+
const r = spawnCoordBinSync(bin, ["journal", "recovery-cue"], {
|
|
93
93
|
env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
|
|
94
94
|
timeout: 5000,
|
|
95
95
|
encoding: "utf8",
|
|
@@ -105,7 +105,7 @@ export function journalArchive(repoRoot: string, owner: string): void {
|
|
|
105
105
|
try {
|
|
106
106
|
const bin = harnBin(repoRoot);
|
|
107
107
|
if (!bin || !owner) return;
|
|
108
|
-
|
|
108
|
+
spawnCoordBinSync(bin, ["journal", "archive", "--owner", owner], {
|
|
109
109
|
env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
|
|
110
110
|
timeout: 5000,
|
|
111
111
|
stdio: "ignore",
|
|
@@ -91,6 +91,7 @@ function gitRevParse(start: string, flag: string): string | null {
|
|
|
91
91
|
// A stale adapter project directory may no longer exist. Treat spawn
|
|
92
92
|
// failure as no path evidence instead of turning discovery into an error.
|
|
93
93
|
const result = spawnSync("git", ["rev-parse", flag], {
|
|
94
|
+
windowsHide: true,
|
|
94
95
|
cwd: start,
|
|
95
96
|
encoding: "utf8",
|
|
96
97
|
});
|
|
@@ -94,6 +94,67 @@ export function scanAssistantStatusBoxPresent(
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** A handoff older than this no longer defers Stop, so a lost notification cannot disable the ritual. */
|
|
98
|
+
export const BACKGROUND_HANDOFF_MAX_AGE_MS = 30 * 60 * 1000;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Claude Code interleaves request-capture rows of several hundred KB each, so
|
|
102
|
+
* the 256KB status-box window can lose a handoff written a minute earlier.
|
|
103
|
+
*/
|
|
104
|
+
const BACKGROUND_HANDOFF_TAIL_BYTES = 8 * 1024 * 1024;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Claude Code background tasks it moved off the foreground on its own (to
|
|
108
|
+
* deliver a mid-command user message) that have not yet posted a
|
|
109
|
+
* `<task-notification>`. Only the structured `toolUseResult` fields written by
|
|
110
|
+
* Claude Code open a handoff; the agent cannot forge them through tool output.
|
|
111
|
+
* Any later notification text naming the task closes it, so a spoofed
|
|
112
|
+
* notification can only make Stop stricter. A command the agent chose to
|
|
113
|
+
* background (`run_in_background`) or a Monitor opens nothing.
|
|
114
|
+
*/
|
|
115
|
+
export function scanPendingBackgroundHandoffs(
|
|
116
|
+
transcriptPath: string | undefined,
|
|
117
|
+
nowMs: number = Date.now(),
|
|
118
|
+
): string[] {
|
|
119
|
+
const readablePath = resolveTranscriptPath(transcriptPath);
|
|
120
|
+
if (!readablePath) return [];
|
|
121
|
+
let size: number;
|
|
122
|
+
try {
|
|
123
|
+
size = statSync(readablePath).size;
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const text = readRange(readablePath, Math.max(0, size - BACKGROUND_HANDOFF_TAIL_BYTES), size);
|
|
128
|
+
if (!text) return [];
|
|
129
|
+
const opened = new Map<string, number>();
|
|
130
|
+
for (const line of text.split("\n")) {
|
|
131
|
+
// Request-capture rows echo the same text; the row-type check below keeps
|
|
132
|
+
// only Claude Code's own top-level tool-result rows.
|
|
133
|
+
if (line.includes("backgroundedToDeliverMessage")) {
|
|
134
|
+
const row = parseTranscriptRow(line);
|
|
135
|
+
const result = row?.toolUseResult as
|
|
136
|
+
| { backgroundTaskId?: unknown; backgroundedToDeliverMessage?: unknown }
|
|
137
|
+
| undefined;
|
|
138
|
+
if (
|
|
139
|
+
row?.type === "user" &&
|
|
140
|
+
result?.backgroundedToDeliverMessage === true &&
|
|
141
|
+
typeof result.backgroundTaskId === "string" &&
|
|
142
|
+
result.backgroundTaskId
|
|
143
|
+
) {
|
|
144
|
+
opened.set(result.backgroundTaskId, Date.parse(String(row.timestamp)));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (line.includes("task-notification")) {
|
|
148
|
+
for (const match of line.matchAll(/<task-id>([^<]+)<\/task-id>/g)) {
|
|
149
|
+
opened.delete(match[1]!);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return [...opened]
|
|
154
|
+
.filter(([, at]) => Number.isFinite(at) && nowMs - at <= BACKGROUND_HANDOFF_MAX_AGE_MS)
|
|
155
|
+
.map(([id]) => id);
|
|
156
|
+
}
|
|
157
|
+
|
|
97
158
|
/** Most recent user-visible assistant text across Claude Code and Codex JSONL. */
|
|
98
159
|
export function scanLatestAssistantText(transcriptPath: string | undefined): string | undefined {
|
|
99
160
|
const readablePath = resolveTranscriptPath(transcriptPath);
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "node:fs";
|
|
18
18
|
import { hostname } from "node:os";
|
|
19
19
|
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
20
21
|
import { coordEnv } from "../../lib/env.ts";
|
|
21
22
|
import {
|
|
22
23
|
type ReadLedgerV3SinceResult,
|
|
@@ -150,7 +151,9 @@ export async function spawnSemanticService(
|
|
|
150
151
|
const sharedLogs = coordEnv("SHARED_LOGS") !== "0";
|
|
151
152
|
const logFd = sharedLogs ? undefined : openSync(paths.log, "a", stateFileMode());
|
|
152
153
|
if (logFd !== undefined) chmodSync(paths.log, stateFileMode());
|
|
153
|
-
const harnBin =
|
|
154
|
+
const harnBin = fileURLToPath(
|
|
155
|
+
new URL(import.meta.url.endsWith(".ts") ? "../../cli.ts" : "../../cli.js", import.meta.url),
|
|
156
|
+
);
|
|
154
157
|
if (!existsSync(harnBin)) {
|
|
155
158
|
if (logFd !== undefined) closeSync(logFd);
|
|
156
159
|
throw new Error(`cannot find harn executable at ${harnBin}`);
|
|
@@ -160,7 +163,8 @@ export async function spawnSemanticService(
|
|
|
160
163
|
args.push("--calls-per-hour", String(options.callsPerHour));
|
|
161
164
|
}
|
|
162
165
|
let spawnError: Error | undefined;
|
|
163
|
-
const child = spawn(harnBin, args, {
|
|
166
|
+
const child = spawn(process.execPath, [harnBin, ...args], {
|
|
167
|
+
windowsHide: true,
|
|
164
168
|
cwd: coordRoot,
|
|
165
169
|
detached: true,
|
|
166
170
|
stdio: logFd === undefined ? ["ignore", "ignore", "ignore"] : ["ignore", logFd, logFd],
|