harnery 0.31.5 → 0.32.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/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/agents.d.ts +0 -9
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -95
- package/dist/commands/browse-session.d.ts +21 -0
- package/dist/commands/browse-session.d.ts.map +1 -0
- package/dist/commands/browse-session.js +157 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +205 -37
- package/dist/commands/checkpoint.js +1 -1
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +40 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +100 -17
- package/dist/commands/init.d.ts +1 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +16 -5
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +2 -3
- package/dist/core/agents/canonical-emit.d.ts +1 -2
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +1 -2
- package/dist/core/agents/cli.js +123 -88
- package/dist/core/agents/coord-client.d.ts +11 -4
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +27 -9
- package/dist/core/agents/finalization.d.ts +68 -0
- package/dist/core/agents/finalization.d.ts.map +1 -0
- package/dist/core/agents/finalization.js +443 -0
- package/dist/core/agents/git-hook.d.ts +51 -0
- package/dist/core/agents/git-hook.d.ts.map +1 -0
- package/dist/core/agents/git-hook.js +118 -0
- package/dist/core/agents/render/prompt-context.d.ts +6 -5
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +26 -13
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +15 -2
- package/dist/core/agents/rules/claim-conflict.js +3 -3
- package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +21 -5
- package/dist/core/agents/rules/stop-hook.d.ts +3 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +59 -23
- package/dist/core/agents/session-events.d.ts +8 -16
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +12 -26
- package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +44 -1
- package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +54 -17
- package/dist/core/agents/state/names.d.ts +22 -1
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +40 -2
- package/dist/core/config.d.ts +30 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +74 -11
- package/dist/core/governor/planning.d.ts.map +1 -1
- package/dist/core/governor/planning.js +12 -13
- package/dist/core/hooks/adapter/detect.d.ts +2 -4
- package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
- package/dist/core/hooks/adapter/detect.js +4 -14
- package/dist/core/hooks/adapter/output.d.ts +1 -1
- package/dist/core/hooks/adapter/output.d.ts.map +1 -1
- package/dist/core/hooks/adapter/output.js +5 -4
- package/dist/core/hooks/adapter/parse.d.ts +1 -1
- package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
- package/dist/core/hooks/adapter/parse.js +22 -5
- package/dist/core/hooks/adapter/wiring.d.ts +23 -0
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +32 -0
- package/dist/core/hooks/cli.d.ts +1 -2
- package/dist/core/hooks/cli.d.ts.map +1 -1
- package/dist/core/hooks/cli.js +169 -27
- package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
- package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
- package/dist/core/hooks/codex-wsl-bridge.js +137 -0
- package/dist/core/hooks/effects/index.d.ts +1 -1
- package/dist/core/hooks/effects/index.js +3 -3
- package/dist/core/hooks/events/schema.d.ts +27 -0
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.d.ts +7 -1
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +15 -0
- package/dist/core/hooks/resolve/transcript.d.ts +44 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +176 -1
- package/dist/core/hooks/session-name-presence.d.ts +30 -0
- package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
- package/dist/core/hooks/session-name-presence.js +43 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
- package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
- package/dist/core/hooks/unsafe-cross-shell.js +151 -0
- package/dist/core/work/state.d.ts +4 -5
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +5 -8
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts +2 -2
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +2 -2
- package/dist/core/workflow/run-state.d.ts +2 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +2 -2
- package/dist/core/workflow/types.d.ts +3 -3
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.d.ts +2 -2
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/execution.js +5 -0
- package/dist/core/workflow/workspaces/index.d.ts +1 -1
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +1 -2
- package/dist/core/workflow/workspaces/paths.d.ts +2 -2
- package/dist/core/workflow/workspaces/types.d.ts +0 -6
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.d.ts +0 -2
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/validate.js +0 -2
- package/dist/lib/browser/client.d.ts +118 -1
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +435 -6
- package/dist/lib/browser/geometry.d.ts.map +1 -1
- package/dist/lib/browser/geometry.js +180 -37
- package/dist/lib/browser/index.d.ts +5 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +5 -1
- package/dist/lib/browser/netscape-cookies.d.ts +6 -0
- package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
- package/dist/lib/browser/netscape-cookies.js +31 -0
- package/dist/lib/browser/proxy.d.ts +24 -0
- package/dist/lib/browser/proxy.d.ts.map +1 -0
- package/dist/lib/browser/proxy.js +84 -0
- package/dist/lib/browser/runts.d.ts +6 -0
- package/dist/lib/browser/runts.d.ts.map +1 -1
- package/dist/lib/browser/runts.js +21 -4
- package/dist/lib/browser/session-control.d.ts +112 -0
- package/dist/lib/browser/session-control.d.ts.map +1 -0
- package/dist/lib/browser/session-control.js +670 -0
- package/dist/lib/docs-links.d.ts +108 -0
- package/dist/lib/docs-links.d.ts.map +1 -0
- package/dist/lib/docs-links.js +555 -0
- package/dist/lib/exec.d.ts +1 -1
- package/dist/lib/exec.js +1 -1
- package/dist/lib/identities/assume.d.ts +4 -2
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +15 -2
- package/dist/lib/instructions/git-hooks.d.ts +75 -0
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
- package/dist/lib/instructions/git-hooks.js +238 -0
- package/dist/lib/instructions/splice.d.ts +10 -4
- package/dist/lib/instructions/splice.d.ts.map +1 -1
- package/dist/lib/instructions/splice.js +19 -11
- package/package.json +1 -1
- package/schemas/config.schema.json +32 -0
- package/src/commander.ts +2 -0
- package/src/commands/agents.ts +96 -115
- package/src/commands/browse-session.ts +245 -0
- package/src/commands/browse.ts +287 -44
- package/src/commands/checkpoint.ts +1 -1
- package/src/commands/deinit.ts +7 -1
- package/src/commands/docs.ts +56 -0
- package/src/commands/doctor.ts +100 -18
- package/src/commands/init.ts +17 -9
- package/src/commands/tunnel.ts +2 -5
- package/src/core/agents/canonical-emit.ts +1 -2
- package/src/core/agents/cli.ts +137 -88
- package/src/core/agents/coord-client.ts +38 -9
- package/src/core/agents/finalization.ts +595 -0
- package/src/core/agents/git-hook.ts +126 -0
- package/src/core/agents/render/prompt-context.ts +28 -13
- package/src/core/agents/render/session-context.ts +15 -2
- package/src/core/agents/rules/claim-conflict.ts +3 -3
- package/src/core/agents/rules/commit-conflict.ts +35 -8
- package/src/core/agents/rules/stop-hook.ts +71 -23
- package/src/core/agents/session-events.ts +14 -41
- package/src/core/agents/state/heartbeat-projector.ts +45 -1
- package/src/core/agents/state/heartbeat-writer.ts +78 -16
- package/src/core/agents/state/names.ts +54 -2
- package/src/core/config.ts +92 -12
- package/src/core/governor/planning.ts +17 -12
- package/src/core/hooks/adapter/detect.ts +4 -13
- package/src/core/hooks/adapter/output.ts +9 -4
- package/src/core/hooks/adapter/parse.ts +24 -5
- package/src/core/hooks/adapter/wiring.ts +43 -0
- package/src/core/hooks/cli.ts +201 -23
- package/src/core/hooks/codex-wsl-bridge.ts +188 -0
- package/src/core/hooks/effects/index.ts +5 -5
- package/src/core/hooks/events/schema.ts +27 -0
- package/src/core/hooks/resolve/owner.ts +22 -1
- package/src/core/hooks/resolve/transcript.ts +164 -1
- package/src/core/hooks/session-name-presence.ts +52 -0
- package/src/core/hooks/unsafe-cross-shell.ts +160 -0
- package/src/core/work/state.ts +8 -12
- package/src/core/workflow/engine.ts +2 -2
- package/src/core/workflow/index.ts +0 -1
- package/src/core/workflow/proof.ts +4 -4
- package/src/core/workflow/run-state.ts +5 -5
- package/src/core/workflow/types.ts +3 -3
- package/src/core/workflow/workspaces/execution.ts +11 -4
- package/src/core/workflow/workspaces/index.ts +0 -3
- package/src/core/workflow/workspaces/local-git.ts +1 -2
- package/src/core/workflow/workspaces/paths.ts +2 -2
- package/src/core/workflow/workspaces/types.ts +0 -9
- package/src/core/workflow/workspaces/validate.ts +1 -5
- package/src/lib/browser/client.ts +528 -6
- package/src/lib/browser/geometry.ts +197 -37
- package/src/lib/browser/index.ts +39 -0
- package/src/lib/browser/netscape-cookies.ts +39 -0
- package/src/lib/browser/proxy.ts +105 -0
- package/src/lib/browser/runts.ts +27 -3
- package/src/lib/browser/session-control.ts +892 -0
- package/src/lib/docs-links.ts +674 -0
- package/src/lib/exec.ts +1 -1
- package/src/lib/identities/assume.ts +22 -1
- package/src/lib/instructions/git-hooks.ts +259 -0
- package/src/lib/instructions/splice.ts +41 -11
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
export interface CodexWslBridgeStatus {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
detail: string;
|
|
4
|
+
threadIdPresent: boolean;
|
|
5
|
+
wslenvForwardsThreadId: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CodexWslBridgeOptions {
|
|
9
|
+
/** The caller already knows this is a Codex adapter process. */
|
|
10
|
+
expected?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CodexWslWorkspaceLinkMapping {
|
|
14
|
+
/** Absolute path seen by Linux-side tools. */
|
|
15
|
+
linuxRoot: string;
|
|
16
|
+
/** Forward-slash UNC path that Codex Desktop can use as a Markdown destination. */
|
|
17
|
+
hostRoot: string;
|
|
18
|
+
distro: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CodexWslFileLinkTelemetry {
|
|
22
|
+
/** Markdown destinations that still use the Linux root in a Windows-hosted WSL task. */
|
|
23
|
+
wsl_linux_file_link_count: number;
|
|
24
|
+
/** Bounded examples for local diagnosis; omitted when the count is zero. */
|
|
25
|
+
wsl_linux_file_link_examples?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether an adapter-reported workspace is a Linux path exposed through WSL UNC. */
|
|
29
|
+
export function isWslUncPath(value: unknown): boolean {
|
|
30
|
+
if (typeof value !== "string") return false;
|
|
31
|
+
return (
|
|
32
|
+
/^\\\\(?:wsl\.localhost|wsl\$)\\/i.test(value) ||
|
|
33
|
+
/^\\\\\?\\unc\\(?:wsl\.localhost|wsl\$)\\/i.test(value)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ParsedWslUncPath {
|
|
38
|
+
host: string;
|
|
39
|
+
distro: string;
|
|
40
|
+
linuxPath: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseWslUncPath(value: unknown): ParsedWslUncPath | null {
|
|
44
|
+
if (typeof value !== "string") return null;
|
|
45
|
+
const ordinary = value.replace(/^\\\\\?\\UNC\\/i, "\\\\");
|
|
46
|
+
const match = ordinary.match(/^\\\\(wsl\.localhost|wsl\$)\\([^\\]+)(?:\\(.*))?$/i);
|
|
47
|
+
if (!match) return null;
|
|
48
|
+
const host = match[1]!;
|
|
49
|
+
const distro = match[2]!;
|
|
50
|
+
const tail = (match[3] ?? "").split("\\").filter(Boolean).join("/");
|
|
51
|
+
return { host, distro, linuxPath: tail ? `/${tail}` : "/" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function trimTrailingSlash(path: string): string {
|
|
55
|
+
return path === "/" ? path : path.replace(/\/+$/, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isWithinOrEqual(path: string, root: string): boolean {
|
|
59
|
+
return path === root || path.startsWith(`${root}/`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Derive the Linux-to-host path pair from the actual adapter workspace. The
|
|
64
|
+
* roots must overlap so an unrelated WSL UNC path cannot authorize a mapping
|
|
65
|
+
* for some other checkout.
|
|
66
|
+
*/
|
|
67
|
+
export function codexWslWorkspaceLinkMapping(
|
|
68
|
+
coordRootRaw: string,
|
|
69
|
+
workspaceCwd: unknown,
|
|
70
|
+
): CodexWslWorkspaceLinkMapping | null {
|
|
71
|
+
const parsed = parseWslUncPath(workspaceCwd);
|
|
72
|
+
const linuxRoot = trimTrailingSlash(coordRootRaw.replace(/\\/g, "/"));
|
|
73
|
+
if (!parsed || !linuxRoot.startsWith("/")) return null;
|
|
74
|
+
const linuxCwd = trimTrailingSlash(parsed.linuxPath);
|
|
75
|
+
if (!isWithinOrEqual(linuxCwd, linuxRoot) && !isWithinOrEqual(linuxRoot, linuxCwd)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
linuxRoot,
|
|
80
|
+
hostRoot: `//${parsed.host}/${parsed.distro}${linuxRoot}`,
|
|
81
|
+
distro: parsed.distro,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fresh adapter context for the model. Shell paths stay Linux-native; only
|
|
86
|
+
* Markdown destinations use the host-visible root. */
|
|
87
|
+
export function renderCodexWslFileLinkContext(coordRoot: string, workspaceCwd: unknown): string {
|
|
88
|
+
const mapping = codexWslWorkspaceLinkMapping(coordRoot, workspaceCwd);
|
|
89
|
+
if (!mapping) return "";
|
|
90
|
+
return (
|
|
91
|
+
"Codex WSL file links: this task is hosted by Windows while tools run inside WSL. " +
|
|
92
|
+
`For clickable local-file Markdown links, replace the Linux workspace root \`${mapping.linuxRoot}\` with the host-visible root \`${mapping.hostRoot}\`. ` +
|
|
93
|
+
`Example: \`[label](<${mapping.hostRoot}/path/to/file>)\`. ` +
|
|
94
|
+
"Keep Linux paths unchanged in shell commands and tool inputs."
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function withoutMarkdownCode(text: string): string {
|
|
99
|
+
const output: string[] = [];
|
|
100
|
+
let fence: { marker: string; length: number } | null = null;
|
|
101
|
+
for (const line of text.split(/\r?\n/)) {
|
|
102
|
+
const candidate = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
103
|
+
if (fence) {
|
|
104
|
+
if (candidate && candidate[1]![0] === fence.marker && candidate[1]!.length >= fence.length) {
|
|
105
|
+
fence = null;
|
|
106
|
+
}
|
|
107
|
+
output.push("");
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (candidate) {
|
|
111
|
+
fence = { marker: candidate[1]![0]!, length: candidate[1]!.length };
|
|
112
|
+
output.push("");
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
output.push(line.replace(/(`+)([^`\n]*?)\1/g, ""));
|
|
116
|
+
}
|
|
117
|
+
return output.join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function markdownDestinations(text: string): string[] {
|
|
121
|
+
const out: string[] = [];
|
|
122
|
+
const visible = withoutMarkdownCode(text);
|
|
123
|
+
const re = /!?\[[^\]\n]*\]\(\s*(?:<([^>\n]+)>|([^\s)\n]+))/g;
|
|
124
|
+
for (const match of visible.matchAll(re)) {
|
|
125
|
+
const target = match[1] ?? match[2];
|
|
126
|
+
if (target) out.push(target);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Count Markdown links that point at the Linux workspace root even though the
|
|
133
|
+
* adapter workspace is Windows-visible UNC. This is telemetry only: the Stop
|
|
134
|
+
* hook must not replace or retry an otherwise complete Codex reply.
|
|
135
|
+
*/
|
|
136
|
+
export function codexWslFileLinkTelemetry(
|
|
137
|
+
coordRoot: string,
|
|
138
|
+
workspaceCwd: unknown,
|
|
139
|
+
assistantMessage: string,
|
|
140
|
+
): CodexWslFileLinkTelemetry | null {
|
|
141
|
+
const mapping = codexWslWorkspaceLinkMapping(coordRoot, workspaceCwd);
|
|
142
|
+
if (!mapping) return null;
|
|
143
|
+
const mismatches = markdownDestinations(assistantMessage).filter((target) =>
|
|
144
|
+
isWithinOrEqual(target, mapping.linuxRoot),
|
|
145
|
+
);
|
|
146
|
+
return {
|
|
147
|
+
wsl_linux_file_link_count: mismatches.length,
|
|
148
|
+
...(mismatches.length > 0
|
|
149
|
+
? { wsl_linux_file_link_examples: mismatches.slice(0, 3).map((path) => path.slice(0, 240)) }
|
|
150
|
+
: {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function wslenvForwards(wslenv: string | undefined, variable: string): boolean {
|
|
155
|
+
if (!wslenv) return false;
|
|
156
|
+
return wslenv.split(":").some((entry) => entry.split("/", 1)[0] === variable);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Inspect the environment contract used when Windows-native Codex launches a
|
|
161
|
+
* Harnery hook or shell inside WSL. Returns null outside that hybrid mode.
|
|
162
|
+
*/
|
|
163
|
+
export function inspectCodexWslBridge(
|
|
164
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
165
|
+
options: CodexWslBridgeOptions = {},
|
|
166
|
+
): CodexWslBridgeStatus | null {
|
|
167
|
+
const inWsl = Boolean(env.WSL_DISTRO_NAME?.trim() || env.WSL_INTEROP?.trim());
|
|
168
|
+
if (!inWsl) return null;
|
|
169
|
+
|
|
170
|
+
const threadIdPresent = Boolean(env.CODEX_THREAD_ID?.trim());
|
|
171
|
+
const wslenvForwardsThreadId = wslenvForwards(env.WSLENV, "CODEX_THREAD_ID");
|
|
172
|
+
if (!options.expected && !threadIdPresent && !wslenvForwardsThreadId) return null;
|
|
173
|
+
|
|
174
|
+
const problems: string[] = [];
|
|
175
|
+
if (!threadIdPresent) problems.push("CODEX_THREAD_ID did not reach WSL");
|
|
176
|
+
if (!wslenvForwardsThreadId) problems.push("WSLENV does not forward CODEX_THREAD_ID");
|
|
177
|
+
|
|
178
|
+
const distro = env.WSL_DISTRO_NAME?.trim();
|
|
179
|
+
return {
|
|
180
|
+
ok: problems.length === 0,
|
|
181
|
+
detail:
|
|
182
|
+
problems.length === 0
|
|
183
|
+
? `thread identity forwarded through WSLENV${distro ? ` (${distro})` : ""}`
|
|
184
|
+
: problems.join("; "),
|
|
185
|
+
threadIdPresent,
|
|
186
|
+
wslenvForwardsThreadId,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -120,7 +120,7 @@ export function journalArchive(repoRoot: string, owner: string): void {
|
|
|
120
120
|
* Fire the optional host session-sync extension on turn stop / session end.
|
|
121
121
|
* harnery core has no session-telemetry sink of its own; a host that wants one
|
|
122
122
|
* drops an executable at
|
|
123
|
-
* `scripts/hooks/
|
|
123
|
+
* `scripts/hooks/harness/claude-code/extensions/session-sync.sh` under the coord
|
|
124
124
|
* root, and core runs it detached + unref'd so a slow sink never blocks the
|
|
125
125
|
* hook. `force` arrives as argv $1 ("1" on session end, "0" on turn stop) so the
|
|
126
126
|
* host can rate-limit the stop path and force-flush on end. No-op when the
|
|
@@ -134,8 +134,8 @@ export function runSessionSyncExtension(repoRoot: string, force: boolean): void
|
|
|
134
134
|
repoRoot,
|
|
135
135
|
"scripts",
|
|
136
136
|
"hooks",
|
|
137
|
-
"
|
|
138
|
-
"
|
|
137
|
+
"harness",
|
|
138
|
+
"claude-code",
|
|
139
139
|
"extensions",
|
|
140
140
|
"session-sync.sh",
|
|
141
141
|
);
|
|
@@ -169,8 +169,8 @@ export function runTurnSummary(
|
|
|
169
169
|
repoRoot,
|
|
170
170
|
"scripts",
|
|
171
171
|
"hooks",
|
|
172
|
-
"
|
|
173
|
-
"
|
|
172
|
+
"harness",
|
|
173
|
+
"claude-code",
|
|
174
174
|
"extensions",
|
|
175
175
|
"turn-summary.sh",
|
|
176
176
|
);
|
|
@@ -99,6 +99,24 @@ export type TurnStop = EventEnvelope<
|
|
|
99
99
|
tool_call_count: number;
|
|
100
100
|
text_length: number;
|
|
101
101
|
status_box_present: boolean; // adapter sets via transcript scan for `┌─ agent-` prefix
|
|
102
|
+
/** Strict variant of status_box_present: assistant text blocks only, so a
|
|
103
|
+
* tool_result carrying the box (the status command's own output) does not
|
|
104
|
+
* count. Shadow telemetry while the rule-2/3 detector decision is open;
|
|
105
|
+
* the Stop verdict does not read it yet. */
|
|
106
|
+
status_box_present_strict?: boolean;
|
|
107
|
+
/** Whether the session's suggested name is satisfied as of this turn:
|
|
108
|
+
* either this reply showed it, or an earlier reply did. Present whenever a
|
|
109
|
+
* suggested name exists, since the Stop verdict reads it per turn. */
|
|
110
|
+
session_name_present?: boolean;
|
|
111
|
+
/** Which suggested name `session_name_present` refers to. A projector
|
|
112
|
+
* rebuild attributes the sighting to this name instead of to whichever
|
|
113
|
+
* name is current during the replay. */
|
|
114
|
+
session_name_present_for?: string;
|
|
115
|
+
/** Shadow telemetry for Windows-hosted Codex workspaces: Markdown link
|
|
116
|
+
* destinations that still use the Linux coordination root. */
|
|
117
|
+
wsl_linux_file_link_count?: number;
|
|
118
|
+
/** Up to three bounded examples for diagnosing the mismatches locally. */
|
|
119
|
+
wsl_linux_file_link_examples?: string[];
|
|
102
120
|
}
|
|
103
121
|
>;
|
|
104
122
|
|
|
@@ -258,6 +276,10 @@ export type ClaimAcquire = EventEnvelope<
|
|
|
258
276
|
{
|
|
259
277
|
path: string;
|
|
260
278
|
mode: "read" | "write";
|
|
279
|
+
finalization?: {
|
|
280
|
+
disposition: "git" | "output";
|
|
281
|
+
root: string;
|
|
282
|
+
};
|
|
261
283
|
}
|
|
262
284
|
>;
|
|
263
285
|
|
|
@@ -284,6 +306,11 @@ export type StateTaskSet = EventEnvelope<
|
|
|
284
306
|
{
|
|
285
307
|
task: string;
|
|
286
308
|
cleared: boolean;
|
|
309
|
+
/** True exactly when this call produced the session name (first non-empty
|
|
310
|
+
* declaration of a human-facing session); never true with a null name. */
|
|
311
|
+
first_of_session?: boolean;
|
|
312
|
+
/** The name produced by this call, for the Stop-hook naming rule. */
|
|
313
|
+
suggested_session_name?: string | null;
|
|
287
314
|
}
|
|
288
315
|
>;
|
|
289
316
|
|
|
@@ -17,6 +17,12 @@ import { checkPidToken } from "../../agents/state/proc-start.ts";
|
|
|
17
17
|
* events; session_id is the parent-shape default.
|
|
18
18
|
* 3. PID-map lookup at `.harnery/pid-map/<pid>` for our own pid, then ppid
|
|
19
19
|
* chain (up to 20 hops).
|
|
20
|
+
* 4. `CODEX_THREAD_ID` env var, accepted only when a live heartbeat by that
|
|
21
|
+
* id exists under `.harnery/active/`. Codex sessions use their thread id
|
|
22
|
+
* as the heartbeat instance_id, and a Windows→WSL bridge forwards the
|
|
23
|
+
* var into process trees that descend from no registered process — the
|
|
24
|
+
* one place the pid walk can never land. Validated so a garbage value
|
|
25
|
+
* cannot fabricate an identity.
|
|
20
26
|
*
|
|
21
27
|
* Returns null when nothing resolves. Callers must treat null as "no owner"
|
|
22
28
|
* and skip the event (Phase 2 fail-safe; Phase 3 will mint a temporary owner
|
|
@@ -25,7 +31,10 @@ import { checkPidToken } from "../../agents/state/proc-start.ts";
|
|
|
25
31
|
export function resolveOwner(opts: {
|
|
26
32
|
payload: Record<string, unknown> | null;
|
|
27
33
|
coordRoot: string;
|
|
28
|
-
}): {
|
|
34
|
+
}): {
|
|
35
|
+
instance_id: string;
|
|
36
|
+
source: "env" | "payload" | "pidmap-self" | "pidmap-ancestor" | "codex-env";
|
|
37
|
+
} | null {
|
|
29
38
|
const env = coordEnv("AGENT_COORD_OWNER");
|
|
30
39
|
if (env && env.length > 0) {
|
|
31
40
|
return { instance_id: env, source: "env" };
|
|
@@ -76,6 +85,18 @@ export function resolveOwner(opts: {
|
|
|
76
85
|
}
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
// Tier 4: Codex thread id, heartbeat-validated. Last because the pid map is
|
|
89
|
+
// more specific when it answers — an inherited env var can outlive the
|
|
90
|
+
// process tree it described, a live pid-map row cannot.
|
|
91
|
+
const codexThreadId = process.env.CODEX_THREAD_ID?.trim();
|
|
92
|
+
if (
|
|
93
|
+
codexThreadId &&
|
|
94
|
+
/^[A-Za-z0-9-]+$/.test(codexThreadId) &&
|
|
95
|
+
existsSync(join(opts.coordRoot, ".harnery", "active", `${codexThreadId}.json`))
|
|
96
|
+
) {
|
|
97
|
+
return { instance_id: codexThreadId, source: "codex-env" };
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
return null;
|
|
80
101
|
}
|
|
81
102
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Scan a CC-style JSONL transcript for the `┌─ agent-` status-box prefix in
|
|
@@ -20,6 +21,56 @@ export function scanStatusBoxPresent(transcriptPath: string | undefined): boolea
|
|
|
20
21
|
return text.includes("┌─ agent-");
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Scan a CC-style JSONL transcript for a needle in ASSISTANT message text
|
|
26
|
+
* blocks only. Used by `turn.stop` to detect the suggested session name in the
|
|
27
|
+
* reply.
|
|
28
|
+
*
|
|
29
|
+
* The row filter is load-bearing: the transcript tail also carries the needle
|
|
30
|
+
* inside `tool_result` rows (the `agents set-task` JSON output) and hook
|
|
31
|
+
* context rows, so a raw `includes()` over the tail — the way the status-box
|
|
32
|
+
* scan works — would false-pass the moment set-task runs. Verified empirically
|
|
33
|
+
* 2026-08-09 against a live transcript.
|
|
34
|
+
*
|
|
35
|
+
* Same 256KB tail window as the other scans; the window's first line may be a
|
|
36
|
+
* truncated JSON row, which the per-line parse guard skips.
|
|
37
|
+
*/
|
|
38
|
+
export function scanAssistantTextIncludes(
|
|
39
|
+
transcriptPath: string | undefined,
|
|
40
|
+
needle: string,
|
|
41
|
+
): boolean {
|
|
42
|
+
if (!transcriptPath || !needle || !existsSync(transcriptPath)) return false;
|
|
43
|
+
const text = tailText(transcriptPath);
|
|
44
|
+
if (!text) return false;
|
|
45
|
+
for (const line of text.split("\n")) {
|
|
46
|
+
// Cheap gate before parsing: most lines don't contain the needle at all.
|
|
47
|
+
if (!line.includes(needle)) continue;
|
|
48
|
+
try {
|
|
49
|
+
const row = JSON.parse(line) as {
|
|
50
|
+
type?: unknown;
|
|
51
|
+
message?: { content?: unknown };
|
|
52
|
+
};
|
|
53
|
+
if (row.type !== "assistant") continue;
|
|
54
|
+
const content = row.message?.content;
|
|
55
|
+
if (typeof content === "string") {
|
|
56
|
+
if (content.includes(needle)) return true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(content)) continue;
|
|
60
|
+
for (const block of content) {
|
|
61
|
+
if (typeof block !== "object" || block === null) continue;
|
|
62
|
+
const b = block as { type?: unknown; text?: unknown };
|
|
63
|
+
if (b.type === "text" && typeof b.text === "string" && b.text.includes(needle)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Truncated/partial line (tail-window start or flush race); skip it.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
23
74
|
/**
|
|
24
75
|
* Resolve the agent's model from a CC-style JSONL transcript by reading the
|
|
25
76
|
* most-recent assistant message's `message.model`. Claude Code's SessionStart
|
|
@@ -56,6 +107,118 @@ export function scanTranscriptModel(transcriptPath: string | undefined): string
|
|
|
56
107
|
return undefined;
|
|
57
108
|
}
|
|
58
109
|
|
|
110
|
+
const SESSION_FILE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
|
|
111
|
+
/** Skip absurdly large transcripts rather than buffering them. */
|
|
112
|
+
const FORK_SCAN_MAX_BYTES = 128 * 1024 * 1024;
|
|
113
|
+
/** How many message uuids to sample across the fork's copied prefix. */
|
|
114
|
+
const FORK_SAMPLE_SIZE = 24;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Detect the parent session of a forked CC conversation from its transcript.
|
|
118
|
+
*
|
|
119
|
+
* Empirical basis (verified 2026-08-05 against `claude --resume <id>
|
|
120
|
+
* --fork-session`): the fork copies the parent's rows into a new
|
|
121
|
+
* `<new-session-id>.jsonl`, rewriting every row's `sessionId` to the new id
|
|
122
|
+
* but PRESERVING each copied message row's `uuid`. So a sibling transcript in
|
|
123
|
+
* the same project directory that contains the fork's message uuids is an
|
|
124
|
+
* ancestor of the fork.
|
|
125
|
+
*
|
|
126
|
+
* Ancestor vs. parent: a grandparent (when the parent was itself a fork)
|
|
127
|
+
* shares the fork's early uuids too, but only the true parent contains the
|
|
128
|
+
* whole copied prefix. At detection time the fork's transcript is almost
|
|
129
|
+
* entirely copied prefix, so uuids sampled across the whole file separate the
|
|
130
|
+
* two: score candidates by containment, highest wins.
|
|
131
|
+
*
|
|
132
|
+
* Sibling forks force a second rule: a PRIOR fork of the same parent also
|
|
133
|
+
* contains the whole copied prefix, so it ties the true parent on score. The
|
|
134
|
+
* sibling additionally carries its own post-fork turns, which the parent
|
|
135
|
+
* lacks, so among tied scores the MINIMAL container (fewest message rows)
|
|
136
|
+
* wins. A wrong pick under this rule stays within the ancestry (grandparent
|
|
137
|
+
* at worst); a recency tie-break can name a non-ancestor sibling (observed
|
|
138
|
+
* 2026-08-05 with two probes forked off one parent).
|
|
139
|
+
*
|
|
140
|
+
* Fail-soft by design: any read error, an unflushed transcript, or no
|
|
141
|
+
* qualifying sibling returns undefined, which downgrades the caller to the
|
|
142
|
+
* no-lineage behavior. Never throws.
|
|
143
|
+
*/
|
|
144
|
+
export function detectForkParent(
|
|
145
|
+
transcriptPath: string | undefined,
|
|
146
|
+
sessionId: string,
|
|
147
|
+
): string | undefined {
|
|
148
|
+
try {
|
|
149
|
+
if (!transcriptPath || !sessionId || !existsSync(transcriptPath)) return undefined;
|
|
150
|
+
if (statSync(transcriptPath).size > FORK_SCAN_MAX_BYTES) return undefined;
|
|
151
|
+
const own = readFileSync(transcriptPath, "utf8");
|
|
152
|
+
const uuids = messageUuids(own);
|
|
153
|
+
if (uuids.length === 0) return undefined;
|
|
154
|
+
const sample = sampleEvenly(uuids, FORK_SAMPLE_SIZE);
|
|
155
|
+
|
|
156
|
+
const dir = dirname(transcriptPath);
|
|
157
|
+
const ownFile = basename(transcriptPath);
|
|
158
|
+
let best: { id: string; score: number; rows: number; mtimeMs: number } | null = null;
|
|
159
|
+
for (const file of readdirSync(dir)) {
|
|
160
|
+
if (file === ownFile || !SESSION_FILE_RE.test(file)) continue;
|
|
161
|
+
const candidateId = file.slice(0, -".jsonl".length);
|
|
162
|
+
if (candidateId === sessionId) continue;
|
|
163
|
+
const path = join(dir, file);
|
|
164
|
+
let stat: ReturnType<typeof statSync>;
|
|
165
|
+
try {
|
|
166
|
+
stat = statSync(path);
|
|
167
|
+
} catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (stat.size > FORK_SCAN_MAX_BYTES) continue;
|
|
171
|
+
let text: string;
|
|
172
|
+
try {
|
|
173
|
+
text = readFileSync(path, "utf8");
|
|
174
|
+
} catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// Cheap gate before scoring: an ancestor must contain the fork's first
|
|
178
|
+
// copied message uuid (the copy always starts at the beginning).
|
|
179
|
+
if (!text.includes(sample[0]!)) continue;
|
|
180
|
+
let score = 0;
|
|
181
|
+
for (const u of sample) if (text.includes(u)) score++;
|
|
182
|
+
if (score < Math.max(1, Math.ceil(sample.length / 2))) continue;
|
|
183
|
+
const rows = messageUuids(text).length;
|
|
184
|
+
const wins =
|
|
185
|
+
!best ||
|
|
186
|
+
score > best.score ||
|
|
187
|
+
(score === best.score &&
|
|
188
|
+
(rows < best.rows || (rows === best.rows && stat.mtimeMs < best.mtimeMs)));
|
|
189
|
+
if (wins) best = { id: candidateId, score, rows, mtimeMs: stat.mtimeMs };
|
|
190
|
+
}
|
|
191
|
+
return best?.id;
|
|
192
|
+
} catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Message-row uuids (user/assistant rows only), in file order. */
|
|
198
|
+
function messageUuids(text: string): string[] {
|
|
199
|
+
const out: string[] = [];
|
|
200
|
+
for (const line of text.split("\n")) {
|
|
201
|
+
if (!line.includes('"uuid"')) continue;
|
|
202
|
+
try {
|
|
203
|
+
const row = JSON.parse(line) as { type?: unknown; uuid?: unknown };
|
|
204
|
+
if (row.type !== "user" && row.type !== "assistant") continue;
|
|
205
|
+
if (typeof row.uuid === "string" && row.uuid.length > 0) out.push(row.uuid);
|
|
206
|
+
} catch {
|
|
207
|
+
// Truncated/partial line (flush race); skip it.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Up to <n> items spread evenly across the list, always including the first. */
|
|
214
|
+
function sampleEvenly(items: string[], n: number): string[] {
|
|
215
|
+
if (items.length <= n) return items;
|
|
216
|
+
const out: string[] = [];
|
|
217
|
+
const step = items.length / n;
|
|
218
|
+
for (let i = 0; i < n; i++) out.push(items[Math.floor(i * step)]!);
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
59
222
|
/** Tail-read up to 256KB from the end of a file as UTF-8, or undefined on error. */
|
|
60
223
|
function tailText(path: string): string | undefined {
|
|
61
224
|
try {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-name presence for `turn.stop`.
|
|
3
|
+
*
|
|
4
|
+
* While the heartbeat carries a `suggested_session_name`, report whether the
|
|
5
|
+
* naming ritual is satisfied: either this turn's reply shows the name, or an
|
|
6
|
+
* earlier reply already did. Scanning covers assistant text blocks only (the
|
|
7
|
+
* raw transcript tail also carries the name inside the set-task tool_result,
|
|
8
|
+
* which must not count) plus the adapter-supplied last assistant message, for
|
|
9
|
+
* stops that arrive without a transcript.
|
|
10
|
+
*
|
|
11
|
+
* Two properties the stop-hook naming rule depends on:
|
|
12
|
+
*
|
|
13
|
+
* 1. Once a name is satisfied, every later stop keeps reporting `true` rather
|
|
14
|
+
* than omitting the field. The rule wants an in-window `turn.stop` carrying
|
|
15
|
+
* `session_name_present: true`; omitting it after the heartbeat stamp meant
|
|
16
|
+
* nothing could ever re-emit the flag, so every subsequent reply blocked --
|
|
17
|
+
* including the replies reproducing the exact name the rule asked for.
|
|
18
|
+
* 2. The result names WHICH suggested name it covered, so a projector rebuild
|
|
19
|
+
* attributes the sighting to that name instead of to whichever name is
|
|
20
|
+
* current during the replay. Without it, a re-minted name inherited an older
|
|
21
|
+
* name's sighting and looked satisfied without ever having been shown.
|
|
22
|
+
*
|
|
23
|
+
* Never throws: coordination telemetry must not take down a hook.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readHeartbeat, stampSessionNameSeen } from "../agents/state/heartbeat-writer.ts";
|
|
27
|
+
|
|
28
|
+
export interface SessionNamePresence {
|
|
29
|
+
session_name_present?: boolean;
|
|
30
|
+
session_name_present_for?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sessionNamePresence(
|
|
34
|
+
coordRoot: string,
|
|
35
|
+
instanceId: string,
|
|
36
|
+
lastAssistantMessage: string,
|
|
37
|
+
scanAssistantText: (name: string) => boolean,
|
|
38
|
+
): SessionNamePresence {
|
|
39
|
+
try {
|
|
40
|
+
const hb = readHeartbeat(coordRoot, instanceId);
|
|
41
|
+
const name = hb?.suggested_session_name;
|
|
42
|
+
if (!name) return {};
|
|
43
|
+
if (hb?.session_name_seen_for === name) {
|
|
44
|
+
return { session_name_present: true, session_name_present_for: name };
|
|
45
|
+
}
|
|
46
|
+
const present = scanAssistantText(name) || lastAssistantMessage.includes(name);
|
|
47
|
+
if (present) stampSessionNameSeen(coordRoot, instanceId, name);
|
|
48
|
+
return { session_name_present: present, session_name_present_for: name };
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|