@gajae-code/coding-agent 0.18.0 → 0.18.5
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/CHANGELOG.md +95 -0
- package/dist/types/cli/stats-cli.d.ts +0 -5
- package/dist/types/config/autorouting-tier-map.d.ts +29 -2
- package/dist/types/config/model-registry.d.ts +16 -1
- package/dist/types/config/settings-schema.d.ts +2 -2
- package/dist/types/exa/mcp-client.d.ts +1 -5
- package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +1 -0
- package/dist/types/hashline/parser.d.ts +28 -5
- package/dist/types/modes/acp/acp-agent.d.ts +6 -1
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
- package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
- package/dist/types/sdk/broker/runtime.d.ts +20 -0
- package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
- package/dist/types/sdk/bus/index.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/client/client.d.ts +5 -1
- package/dist/types/sdk/host/session-runtime.d.ts +7 -0
- package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/task/index.d.ts +0 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/descriptors.d.ts +5 -0
- package/dist/types/tools/eval-backends.d.ts +37 -0
- package/dist/types/tools/eval.d.ts +1 -6
- package/dist/types/tools/index.d.ts +1 -36
- package/dist/types/tools/session-descriptions.d.ts +22 -0
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/cli/doctor/managed-link.ts +4 -4
- package/src/cli/doctor/repairs.ts +9 -1
- package/src/cli/stats-cli.ts +4 -36
- package/src/cli-main.ts +4 -8
- package/src/cli.ts +4 -8
- package/src/commands/commit.ts +2 -2
- package/src/commands/sdk.ts +1 -2
- package/src/config/autorouting-tier-map.ts +20 -2
- package/src/config/model-preset-registry.ts +17 -2
- package/src/config/model-profile-activation.ts +3 -1
- package/src/config/model-profiles.ts +11 -3
- package/src/config/model-registry.ts +45 -17
- package/src/config/settings-schema.ts +8 -2
- package/src/exa/mcp-client.ts +41 -8
- package/src/exec/bash-shell-guardian.ts +214 -48
- package/src/gjc-runtime/session-state-sidecar.ts +88 -58
- package/src/hashline/execute.ts +51 -3
- package/src/hashline/parser.ts +63 -16
- package/src/internal-urls/docs-index.generated.ts +25 -25
- package/src/modes/acp/acp-agent.ts +136 -20
- package/src/modes/controllers/ssh-command-controller.ts +3 -1
- package/src/modes/interactive-mode.ts +10 -0
- package/src/modes/utils/context-usage.ts +2 -6
- package/src/prompts/tools/task.md +8 -27
- package/src/sdk/acp/adapter.ts +38 -3
- package/src/sdk/broker/doctor-restart.ts +38 -3
- package/src/sdk/broker/lifecycle.ts +408 -218
- package/src/sdk/broker/recovery-backoff.ts +135 -0
- package/src/sdk/broker/runtime.ts +83 -0
- package/src/sdk/broker/startup-budget.ts +6 -1
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/index.ts +308 -22
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/client/client.ts +53 -35
- package/src/sdk/host/session-runtime.ts +99 -3
- package/src/sdk/prompt-watchdog.ts +17 -4
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/session/agent-session.ts +79 -17
- package/src/session/internal/managed-session-storage.ts +23 -0
- package/src/task/index.ts +21 -66
- package/src/tools/bash.ts +39 -0
- package/src/tools/descriptors.ts +27 -1
- package/src/tools/eval-backends.ts +95 -0
- package/src/tools/eval.ts +3 -15
- package/src/tools/index.ts +9 -88
- package/src/tools/search.ts +2 -7
- package/src/tools/session-descriptions.ts +135 -0
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/dist/types/cli/grep-cli.d.ts +0 -17
- package/dist/types/cli/shell-cli.d.ts +0 -8
- package/dist/types/commands/grep.d.ts +0 -42
- package/dist/types/commands/shell.d.ts +0 -21
- package/src/cli/grep-cli.ts +0 -160
- package/src/cli/shell-cli.ts +0 -176
- package/src/commands/grep.ts +0 -48
- package/src/commands/shell.ts +0 -29
package/src/exa/mcp-client.ts
CHANGED
|
@@ -187,7 +187,16 @@ export function formatSearchResults(data: ExaSearchResponse): string {
|
|
|
187
187
|
* Format a non-search MCP response as human-readable text.
|
|
188
188
|
* Handles objects, arrays, primitives, and common MCP response shapes.
|
|
189
189
|
*/
|
|
190
|
-
|
|
190
|
+
// Nesting depth is bounded. Each object level re-indents the entire formatted
|
|
191
|
+
// subtree, so cost grows super-linearly with depth: 500/1000/2000/4000/8000
|
|
192
|
+
// levels cost 60ms/227ms/730ms/5.9s/94s. A tool-call payload is whatever the
|
|
193
|
+
// remote MCP server returned, and `MCP_MAX_CONTENT_BYTES` does not bound depth
|
|
194
|
+
// — 8000 levels of `{"a":` is under 47 KB. The limit matches the one the
|
|
195
|
+
// session-import walker already applies (`sanitizeImportedValue`, depth > 64).
|
|
196
|
+
const MAX_GENERIC_RESPONSE_DEPTH = 64;
|
|
197
|
+
|
|
198
|
+
export function formatGenericResponse(data: unknown, depth = 0): string {
|
|
199
|
+
if (depth > MAX_GENERIC_RESPONSE_DEPTH) return "[depth-limit]";
|
|
191
200
|
if (data === null || data === undefined) return "No result.";
|
|
192
201
|
if (typeof data === "string") return data;
|
|
193
202
|
if (typeof data === "number" || typeof data === "boolean") return String(data);
|
|
@@ -199,14 +208,22 @@ export function formatGenericResponse(data: unknown): string {
|
|
|
199
208
|
const item = data[i];
|
|
200
209
|
if (typeof item === "object" && item !== null) {
|
|
201
210
|
const record = item as Record<string, unknown>;
|
|
202
|
-
const
|
|
211
|
+
const rawTitle = record.title ?? record.name ?? record.id;
|
|
212
|
+
// A remote title is unvalidated JSON: stringify non-strings through the
|
|
213
|
+
// depth-bounded path instead of template coercion, which recurses unbounded.
|
|
214
|
+
const title =
|
|
215
|
+
rawTitle === undefined || rawTitle === null
|
|
216
|
+
? `Item ${i + 1}`
|
|
217
|
+
: typeof rawTitle === "string"
|
|
218
|
+
? rawTitle
|
|
219
|
+
: formatValue(rawTitle, depth + 2);
|
|
203
220
|
parts.push(`\n### ${title}`);
|
|
204
221
|
for (const [k, v] of Object.entries(record)) {
|
|
205
222
|
if (["title", "name", "id"].includes(k)) continue;
|
|
206
|
-
parts.push(`- **${k}:** ${formatValue(v)}`);
|
|
223
|
+
parts.push(`- **${k}:** ${formatValue(v, depth + 2)}`);
|
|
207
224
|
}
|
|
208
225
|
} else {
|
|
209
|
-
parts.push(`- ${formatValue(item)}`);
|
|
226
|
+
parts.push(`- ${formatValue(item, depth + 1)}`);
|
|
210
227
|
}
|
|
211
228
|
}
|
|
212
229
|
return parts.join("\n");
|
|
@@ -231,10 +248,10 @@ export function formatGenericResponse(data: unknown): string {
|
|
|
231
248
|
if (k === "content") continue; // handled above
|
|
232
249
|
if (v === null || v === undefined) continue;
|
|
233
250
|
if (typeof v === "object") {
|
|
234
|
-
const formatted = formatGenericResponse(v);
|
|
251
|
+
const formatted = formatGenericResponse(v, depth + 1);
|
|
235
252
|
if (formatted) lines.push(`- **${k}:**\n${indent(formatted, 2)}`);
|
|
236
253
|
} else {
|
|
237
|
-
lines.push(`- **${k}:** ${formatValue(v)}`);
|
|
254
|
+
lines.push(`- **${k}:** ${formatValue(v, depth + 1)}`);
|
|
238
255
|
}
|
|
239
256
|
}
|
|
240
257
|
return lines.join("\n") || "(empty)";
|
|
@@ -243,12 +260,28 @@ export function formatGenericResponse(data: unknown): string {
|
|
|
243
260
|
return String(data);
|
|
244
261
|
}
|
|
245
262
|
|
|
246
|
-
function formatValue(v: unknown): string {
|
|
263
|
+
function formatValue(v: unknown, depth: number): string {
|
|
247
264
|
if (v === null || v === undefined) return "—";
|
|
248
|
-
if (typeof v === "object") return JSON.stringify(v);
|
|
265
|
+
if (typeof v === "object") return JSON.stringify(truncateDepth(v, depth));
|
|
249
266
|
return String(v);
|
|
250
267
|
}
|
|
251
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Copies a value, replacing anything nested past MAX_GENERIC_RESPONSE_DEPTH with
|
|
271
|
+
* "[depth-limit]", so JSON.stringify never recurses through a remote payload's
|
|
272
|
+
* unbounded structure.
|
|
273
|
+
*/
|
|
274
|
+
function truncateDepth(value: unknown, depth: number): unknown {
|
|
275
|
+
if (value === null || typeof value !== "object") return value;
|
|
276
|
+
if (depth > MAX_GENERIC_RESPONSE_DEPTH) return "[depth-limit]";
|
|
277
|
+
if (Array.isArray(value)) return value.map(item => truncateDepth(item, depth + 1));
|
|
278
|
+
// Null prototype: an own "__proto__" key from a parsed payload must stay an
|
|
279
|
+
// own data property instead of hitting the Object.prototype setter.
|
|
280
|
+
const copy: Record<string, unknown> = Object.create(null);
|
|
281
|
+
for (const [key, child] of Object.entries(value)) copy[key] = truncateDepth(child, depth + 1);
|
|
282
|
+
return copy;
|
|
283
|
+
}
|
|
284
|
+
|
|
252
285
|
function indent(text: string, spaces: number): string {
|
|
253
286
|
const pad = " ".repeat(spaces);
|
|
254
287
|
return text
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
2
|
import * as childProcess from "node:child_process";
|
|
3
3
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import * as fsSync from "node:fs";
|
|
4
5
|
import * as fs from "node:fs/promises";
|
|
5
6
|
import * as os from "node:os";
|
|
6
7
|
import * as path from "node:path";
|
|
@@ -63,10 +64,10 @@ export function extendOwnedDarwinAncestry<T extends { uniqueId: bigint; parentUn
|
|
|
63
64
|
return addedPids;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
function authenticateOwnershipRecord(
|
|
67
|
+
export function authenticateOwnershipRecord(
|
|
67
68
|
line: string,
|
|
68
69
|
ledgerToken: string,
|
|
69
|
-
): { processRef?: NativeProcess; darwinUniqueId?: bigint } | undefined {
|
|
70
|
+
): { processRef?: NativeProcess; darwinUniqueId?: bigint; pending?: true } | undefined {
|
|
70
71
|
const record = parseOwnershipRecord(line);
|
|
71
72
|
if (!record) return undefined;
|
|
72
73
|
const uniqueId = record.darwinUniqueId ?? "";
|
|
@@ -80,15 +81,20 @@ function authenticateOwnershipRecord(
|
|
|
80
81
|
return undefined;
|
|
81
82
|
}
|
|
82
83
|
if (received.byteLength !== expected.byteLength || !timingSafeEqual(received, expected)) return undefined;
|
|
83
|
-
if (process.platform === "darwin" && !record.darwinUniqueId) return undefined;
|
|
84
84
|
const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
|
|
85
85
|
const owned = Process.fromPid(record.pid);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
const darwinUniqueId = record.darwinUniqueId ? { darwinUniqueId: BigInt(record.darwinUniqueId) } : {};
|
|
87
|
+
if (owned?.incarnation === record.incarnation) return { processRef: owned, ...darwinUniqueId };
|
|
88
|
+
// fromPid() conflates death with an inconclusive lookup. Only a confirmed
|
|
89
|
+
// absence or a different incarnation settles the record; an unknown result
|
|
90
|
+
// keeps it pending so the caller retries the line on the next scan.
|
|
91
|
+
if (!owned && Process.observe(record.pid).status === "unknown") return { pending: true, ...darwinUniqueId };
|
|
92
|
+
return darwinUniqueId;
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/** Scans an inconclusive ledger record may stay pending before tracking fails closed. */
|
|
96
|
+
const MAX_PENDING_LEDGER_ATTEMPTS = 50;
|
|
97
|
+
|
|
92
98
|
function enableLinuxChildSubreaper(): boolean {
|
|
93
99
|
if (process.platform !== "linux") return true;
|
|
94
100
|
try {
|
|
@@ -157,14 +163,27 @@ function findDarwinLedgerHolders(device: number, inode: bigint): NativeProcess[]
|
|
|
157
163
|
}
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
type DarwinAncestryTracker = {
|
|
166
|
+
export type DarwinAncestryTracker = {
|
|
161
167
|
seed(uniqueId: bigint): void;
|
|
162
168
|
track(processRef: NativeProcess, uniqueId?: bigint): boolean;
|
|
169
|
+
trackGuardian(processRef: NativeProcess, uniqueId?: bigint): boolean;
|
|
163
170
|
poll(): boolean;
|
|
164
171
|
close(): void;
|
|
165
172
|
};
|
|
166
173
|
|
|
167
|
-
|
|
174
|
+
export type DarwinIdentity = { uniqueId: bigint; parentUniqueId: bigint };
|
|
175
|
+
|
|
176
|
+
/** Test seams for the Darwin tracker; production uses libproc and the natives binding. */
|
|
177
|
+
export type DarwinAncestryTrackerDeps = {
|
|
178
|
+
uniqueIdentity?: (pid: number) => DarwinIdentity | undefined;
|
|
179
|
+
fromPid?: (pid: number) => NativeProcess | null | undefined;
|
|
180
|
+
observe?: (pid: number) => { status: "present" | "absent" | "unknown"; incarnation?: string };
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export function createDarwinAncestryTracker(
|
|
184
|
+
owned: Map<string, NativeProcess>,
|
|
185
|
+
deps: DarwinAncestryTrackerDeps = {},
|
|
186
|
+
): DarwinAncestryTracker | undefined {
|
|
168
187
|
if (process.platform !== "darwin") return undefined;
|
|
169
188
|
try {
|
|
170
189
|
const proc = dlopen("/usr/lib/libproc.dylib", {
|
|
@@ -175,18 +194,113 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
|
|
|
175
194
|
},
|
|
176
195
|
});
|
|
177
196
|
const knownUniqueIds = new Set<bigint>();
|
|
197
|
+
// Ancestry evidence (knownUniqueIds) and successful retention are tracked
|
|
198
|
+
// separately: a descendant whose process validation fails stays known, so its
|
|
199
|
+
// own children still chain, but it is retried on every poll until retained.
|
|
200
|
+
const retainedUniqueIds = new Set<bigint>();
|
|
178
201
|
const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
202
|
+
const fromPid = deps.fromPid ?? ((pid: number) => Process.fromPid(pid));
|
|
203
|
+
const observe = deps.observe ?? ((pid: number) => Process.observe(pid));
|
|
204
|
+
// Reuse buffer across poll iterations to avoid allocations inside the loop.
|
|
205
|
+
const identityBuffer = new Uint8Array(56);
|
|
206
|
+
const identityView = new DataView(identityBuffer.buffer);
|
|
207
|
+
const defaultUniqueIdentity = (pid: number): { uniqueId: bigint; parentUniqueId: bigint } | undefined => {
|
|
208
|
+
const bytes = proc.symbols.proc_pidinfo(pid, 17, 0, ptr(identityBuffer), identityBuffer.byteLength);
|
|
209
|
+
if (bytes !== identityBuffer.byteLength) return undefined;
|
|
210
|
+
return {
|
|
211
|
+
uniqueId: identityView.getBigUint64(16, true),
|
|
212
|
+
parentUniqueId: identityView.getBigUint64(24, true),
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
const uniqueIdentity = deps.uniqueIdentity ?? defaultUniqueIdentity;
|
|
216
|
+
const incarnationOnlyAnchors: NativeProcess[] = [];
|
|
217
|
+
const anchorKeys = new Set<string>();
|
|
218
|
+
const addIncarnationOnlyAnchor = (processRef: NativeProcess): void => {
|
|
219
|
+
const key = `${processRef.pid}:${processRef.incarnation}`;
|
|
220
|
+
if (anchorKeys.has(key)) return;
|
|
221
|
+
anchorKeys.add(key);
|
|
222
|
+
incarnationOnlyAnchors.push(processRef);
|
|
223
|
+
};
|
|
224
|
+
// children() validates its parent and then snapshots the process table; the
|
|
225
|
+
// parent can exit and its pid be reused in between, returning a stranger's
|
|
226
|
+
// children. Only trust a snapshot if the parent still holds its incarnation
|
|
227
|
+
// after it was taken.
|
|
228
|
+
const childrenOf = (parent: NativeProcess): NativeProcess[] => {
|
|
229
|
+
const children = parent.children();
|
|
230
|
+
return fromPid(parent.pid)?.incarnation === parent.incarnation ? children : [];
|
|
231
|
+
};
|
|
232
|
+
const seedAnchorDescendants = (anchor: NativeProcess): void => {
|
|
233
|
+
const pending = [...childrenOf(anchor)];
|
|
234
|
+
while (pending.length > 0) {
|
|
235
|
+
const child = pending.pop()!;
|
|
236
|
+
retainOwnedProcess(owned, child);
|
|
237
|
+
// The unique id is read from a bare pid, so bracket it with incarnation
|
|
238
|
+
// checks against this handle: a pid reused in between would otherwise
|
|
239
|
+
// seed an unrelated process (and its descendants) as owned.
|
|
240
|
+
if (fromPid(child.pid)?.incarnation === child.incarnation) {
|
|
241
|
+
const childId = uniqueIdentity(child.pid)?.uniqueId;
|
|
242
|
+
if (fromPid(child.pid)?.incarnation === child.incarnation) {
|
|
243
|
+
if (childId !== undefined) {
|
|
244
|
+
knownUniqueIds.add(childId);
|
|
245
|
+
retainedUniqueIds.add(childId);
|
|
246
|
+
} else {
|
|
247
|
+
// Its flavor-17 query is denied too: without a unique id the
|
|
248
|
+
// ancestry walk can never reach its subtree, so it must outlive
|
|
249
|
+
// this anchor's pruning as an incarnation-only anchor itself.
|
|
250
|
+
addIncarnationOnlyAnchor(child);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
pending.push(...childrenOf(child));
|
|
255
|
+
}
|
|
184
256
|
};
|
|
185
257
|
const track = (processRef: NativeProcess, signedUniqueId?: bigint): boolean => {
|
|
258
|
+
// The bare-pid unique-id query is only trusted if the same incarnation
|
|
259
|
+
// holds on both sides of it; otherwise a pid reused after the record was
|
|
260
|
+
// authenticated would seed the replacement's ancestry as owned.
|
|
261
|
+
let identity: { uniqueId: bigint; parentUniqueId: bigint } | undefined;
|
|
262
|
+
if (signedUniqueId === undefined) {
|
|
263
|
+
const queried = uniqueIdentity(processRef.pid);
|
|
264
|
+
const after = observe(processRef.pid);
|
|
265
|
+
if (
|
|
266
|
+
after.status === "absent" ||
|
|
267
|
+
(after.status === "present" && after.incarnation !== processRef.incarnation)
|
|
268
|
+
) {
|
|
269
|
+
// Confirmed exited or reused since authentication: retain nothing new.
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
// Present with the same incarnation keeps the queried id. An
|
|
273
|
+
// inconclusive lookup cannot vouch for it, so the record falls through
|
|
274
|
+
// as incarnation-only and is kept as an anchor rather than dropped.
|
|
275
|
+
identity = after.status === "present" ? queried : undefined;
|
|
276
|
+
}
|
|
277
|
+
const uniqueId = signedUniqueId ?? identity?.uniqueId;
|
|
278
|
+
if (uniqueId === undefined) {
|
|
279
|
+
// Incarnation-only record: the child has no unique id to anchor the
|
|
280
|
+
// ancestry graph. Keep it as an anchor whose live descendants poll()
|
|
281
|
+
// seeds by unique id, so their subtrees stay tracked after the child
|
|
282
|
+
// exits and they reparent (PPID discovery alone would lose them).
|
|
283
|
+
// Residual gap (accepted over #6085's exit 70): if the child forks a
|
|
284
|
+
// descendant that leaves the supervisor process group, then exits before
|
|
285
|
+
// this record is scanned, nothing links that descendant back to us. The
|
|
286
|
+
// ledger watcher scans on append to keep that window minimal.
|
|
287
|
+
retainOwnedProcess(owned, processRef);
|
|
288
|
+
addIncarnationOnlyAnchor(processRef);
|
|
289
|
+
seedAnchorDescendants(processRef);
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
knownUniqueIds.add(uniqueId);
|
|
293
|
+
retainedUniqueIds.add(uniqueId);
|
|
294
|
+
retainOwnedProcess(owned, processRef);
|
|
295
|
+
return true;
|
|
296
|
+
};
|
|
297
|
+
const trackGuardian = (processRef: NativeProcess, signedUniqueId?: bigint): boolean => {
|
|
186
298
|
const identity = signedUniqueId === undefined ? uniqueIdentity(processRef.pid) : undefined;
|
|
187
299
|
const uniqueId = signedUniqueId ?? identity?.uniqueId;
|
|
300
|
+
// Guardian registration is strict: must have a unique id
|
|
188
301
|
if (uniqueId === undefined) return false;
|
|
189
302
|
knownUniqueIds.add(uniqueId);
|
|
303
|
+
retainedUniqueIds.add(uniqueId);
|
|
190
304
|
retainOwnedProcess(owned, processRef);
|
|
191
305
|
return true;
|
|
192
306
|
};
|
|
@@ -195,28 +309,46 @@ function createDarwinAncestryTracker(owned: Map<string, NativeProcess>): DarwinA
|
|
|
195
309
|
knownUniqueIds.add(uniqueId);
|
|
196
310
|
},
|
|
197
311
|
track,
|
|
312
|
+
trackGuardian,
|
|
198
313
|
poll() {
|
|
314
|
+
// Re-seed live anchors, then drop exited ones: their descendants were
|
|
315
|
+
// seeded by unique id while the anchor was alive, and ancestry now
|
|
316
|
+
// carries them, so re-walking a dead anchor only costs time.
|
|
317
|
+
for (let index = incarnationOnlyAnchors.length - 1; index >= 0; index--) {
|
|
318
|
+
const anchor = incarnationOnlyAnchors[index]!;
|
|
319
|
+
seedAnchorDescendants(anchor);
|
|
320
|
+
if (fromPid(anchor.pid)?.incarnation !== anchor.incarnation) {
|
|
321
|
+
incarnationOnlyAnchors.splice(index, 1);
|
|
322
|
+
anchorKeys.delete(`${anchor.pid}:${anchor.incarnation}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
199
325
|
const capacity = proc.symbols.proc_listallpids(null, 0);
|
|
200
326
|
if (capacity <= 0) return false;
|
|
201
327
|
const pids = new Int32Array(capacity + 64);
|
|
202
328
|
const count = proc.symbols.proc_listallpids(ptr(pids), pids.byteLength);
|
|
203
329
|
if (count <= 0) return false;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
{ uniqueId: bigint; parentUniqueId: bigint; processRef: NativeProcess }
|
|
207
|
-
>();
|
|
330
|
+
// First pass: collect identities for all pids, avoiding Process.fromPid calls.
|
|
331
|
+
const identities = new Map<number, { uniqueId: bigint; parentUniqueId: bigint }>();
|
|
208
332
|
for (let index = 0; index < count; index++) {
|
|
209
333
|
const pid = pids[index]!;
|
|
210
|
-
const processRef = Process.fromPid(pid);
|
|
211
|
-
if (!processRef) continue;
|
|
212
334
|
const identity = uniqueIdentity(pid);
|
|
213
|
-
|
|
214
|
-
if (identity && after?.incarnation === processRef.incarnation) {
|
|
215
|
-
candidates.set(pid, { ...identity, processRef });
|
|
216
|
-
}
|
|
335
|
+
if (identity) identities.set(pid, identity);
|
|
217
336
|
}
|
|
218
|
-
|
|
219
|
-
|
|
337
|
+
// Extend ancestry evidence, then validate every listed pid that is known but
|
|
338
|
+
// not yet retained: new descendants plus earlier ones whose Process.fromPid
|
|
339
|
+
// check failed (it can return null for a live pid it could not query). A pid
|
|
340
|
+
// whose identity changed between the listing and the check was reused; skip it.
|
|
341
|
+
extendOwnedDarwinAncestry(knownUniqueIds, identities);
|
|
342
|
+
for (const [pid, listed] of identities) {
|
|
343
|
+
if (!knownUniqueIds.has(listed.uniqueId) || retainedUniqueIds.has(listed.uniqueId)) continue;
|
|
344
|
+
const before = fromPid(pid);
|
|
345
|
+
if (!before) continue;
|
|
346
|
+
const identity = uniqueIdentity(pid);
|
|
347
|
+
const after = fromPid(pid);
|
|
348
|
+
if (identity?.uniqueId === listed.uniqueId && after?.incarnation === before.incarnation) {
|
|
349
|
+
retainOwnedProcess(owned, after);
|
|
350
|
+
retainedUniqueIds.add(listed.uniqueId);
|
|
351
|
+
}
|
|
220
352
|
}
|
|
221
353
|
return true;
|
|
222
354
|
},
|
|
@@ -259,7 +391,7 @@ export async function runBashShellGuardian(): Promise<void> {
|
|
|
259
391
|
if (darwinTracker) {
|
|
260
392
|
const { Process } = require("@gajae-code/natives") as NativeProcessBindings;
|
|
261
393
|
const guardian = Process.fromPid(process.pid);
|
|
262
|
-
if (!guardian || !darwinTracker.
|
|
394
|
+
if (!guardian || !darwinTracker.trackGuardian(guardian)) {
|
|
263
395
|
darwinTracker.close();
|
|
264
396
|
await ledger.close();
|
|
265
397
|
await fs.rm(ownershipFilePath, { force: true });
|
|
@@ -290,6 +422,8 @@ export async function runBashShellGuardian(): Promise<void> {
|
|
|
290
422
|
input.once("close", () => supervisor.stdin.end());
|
|
291
423
|
let cleaning: Promise<void> | undefined;
|
|
292
424
|
let ledgerBuffer = "";
|
|
425
|
+
let pendingLedgerLines: string[] = [];
|
|
426
|
+
const pendingAttempts = new Map<string, number>();
|
|
293
427
|
let ownershipScan = Promise.resolve(true);
|
|
294
428
|
const scanOwnership = (): Promise<boolean> => {
|
|
295
429
|
ownershipScan = ownershipScan.then(async previousOk => {
|
|
@@ -301,11 +435,23 @@ export async function runBashShellGuardian(): Promise<void> {
|
|
|
301
435
|
return false;
|
|
302
436
|
}
|
|
303
437
|
ledgerBuffer += content;
|
|
304
|
-
const
|
|
305
|
-
ledgerBuffer =
|
|
438
|
+
const fresh = ledgerBuffer.split("\n");
|
|
439
|
+
ledgerBuffer = fresh.pop() ?? "";
|
|
440
|
+
const lines = [...pendingLedgerLines, ...fresh];
|
|
441
|
+
pendingLedgerLines = [];
|
|
306
442
|
for (const line of lines) {
|
|
307
443
|
if (!line) continue;
|
|
308
444
|
const owned = authenticateOwnershipRecord(line, ledgerToken);
|
|
445
|
+
if (owned?.pending) {
|
|
446
|
+
// Inconclusive lookup: retry next scan, bounded so a pid stuck in an
|
|
447
|
+
// unknown state cannot grow the queue or starve cleanup forever.
|
|
448
|
+
const attempts = (pendingAttempts.get(line) ?? 0) + 1;
|
|
449
|
+
if (attempts > MAX_PENDING_LEDGER_ATTEMPTS) return false;
|
|
450
|
+
pendingAttempts.set(line, attempts);
|
|
451
|
+
pendingLedgerLines.push(line);
|
|
452
|
+
} else {
|
|
453
|
+
pendingAttempts.delete(line);
|
|
454
|
+
}
|
|
309
455
|
if (owned?.darwinUniqueId && darwinTracker) darwinTracker.seed(owned.darwinUniqueId);
|
|
310
456
|
if (owned?.processRef && darwinTracker && !darwinTracker.track(owned.processRef, owned.darwinUniqueId))
|
|
311
457
|
return false;
|
|
@@ -319,28 +465,48 @@ export async function runBashShellGuardian(): Promise<void> {
|
|
|
319
465
|
return ownershipScan;
|
|
320
466
|
};
|
|
321
467
|
let periodicScanActive = false;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
468
|
+
let periodicScanRequested = false;
|
|
469
|
+
const runPeriodicScan = (): void => {
|
|
470
|
+
if (periodicScanActive) {
|
|
471
|
+
periodicScanRequested = true;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
periodicScanActive = true;
|
|
475
|
+
void scanOwnership()
|
|
476
|
+
.then(ok => {
|
|
477
|
+
if (ok || supervisor.exitCode !== null || supervisor.signalCode !== null) return;
|
|
478
|
+
if (supervisor.pid) {
|
|
479
|
+
try {
|
|
480
|
+
process.kill(-supervisor.pid, "SIGKILL");
|
|
481
|
+
} catch {}
|
|
482
|
+
}
|
|
483
|
+
supervisor.kill("SIGKILL");
|
|
484
|
+
})
|
|
485
|
+
.finally(() => {
|
|
486
|
+
periodicScanActive = false;
|
|
487
|
+
if (periodicScanRequested && !cleaning) {
|
|
488
|
+
periodicScanRequested = false;
|
|
489
|
+
runPeriodicScan();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
const ownershipScanTimer = darwinTracker ? setInterval(runPeriodicScan, 100) : undefined;
|
|
494
|
+
// Scan as soon as the supervisor appends a ledger record instead of waiting
|
|
495
|
+
// for the next tick. An incarnation-only record's descendants are only
|
|
496
|
+
// reachable while that child is alive, so the anchor must be taken promptly.
|
|
497
|
+
let ledgerWatcher: fsSync.FSWatcher | undefined;
|
|
498
|
+
if (darwinTracker) {
|
|
499
|
+
try {
|
|
500
|
+
ledgerWatcher = fsSync.watch(ownershipFilePath, runPeriodicScan);
|
|
501
|
+
ledgerWatcher.on("error", () => {});
|
|
502
|
+
} catch {
|
|
503
|
+
ledgerWatcher = undefined;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
341
506
|
const cleanup = (): Promise<void> => {
|
|
342
507
|
cleaning ??= (async () => {
|
|
343
508
|
if (ownershipScanTimer) clearInterval(ownershipScanTimer);
|
|
509
|
+
ledgerWatcher?.close();
|
|
344
510
|
let trackingOk = await scanOwnership();
|
|
345
511
|
if (supervisor.exitCode === null && supervisor.signalCode === null) {
|
|
346
512
|
if (process.platform !== "win32" && supervisor.pid) {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { normalizePathForComparison, postmortem } from "@gajae-code/utils";
|
|
13
13
|
import { withFileLock } from "../config/file-lock";
|
|
14
14
|
import {
|
|
15
|
+
type CoordinatorAtomicWriteOptions,
|
|
15
16
|
ensureCoordinatorDirectory,
|
|
16
17
|
syncCoordinatorDirectory,
|
|
17
18
|
syncCoordinatorFile,
|
|
@@ -177,8 +178,25 @@ export const __sessionStateSidecarTestHooks: {
|
|
|
177
178
|
beforeRescopeJournalWrite?: (cwd: string) => void | Promise<void>;
|
|
178
179
|
beforePersistFromEvent?: (eventType: string, cwd: string) => void | Promise<void>;
|
|
179
180
|
beforeRescopePublish?: () => void | Promise<void>;
|
|
181
|
+
disableCoordinatorFileSyncForTests?: boolean;
|
|
180
182
|
} = {};
|
|
181
183
|
|
|
184
|
+
/** Create coordinator atomic write options with test hook support for disabling fsync. */
|
|
185
|
+
function coordinatorAtomicWriteOptionsForTests<T extends CoordinatorAtomicWriteOptions>(
|
|
186
|
+
overrides?: T,
|
|
187
|
+
): CoordinatorAtomicWriteOptions {
|
|
188
|
+
const options = overrides ?? ({} as T);
|
|
189
|
+
if (!__sessionStateSidecarTestHooks.disableCoordinatorFileSyncForTests) {
|
|
190
|
+
return options;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
...options,
|
|
194
|
+
syncFile: async () => {
|
|
195
|
+
/* skip fsync in tests */
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
182
200
|
interface RuntimeStateEvent {
|
|
183
201
|
type: string;
|
|
184
202
|
messages?: unknown[];
|
|
@@ -1806,35 +1824,39 @@ async function writeStateFileSyncConditional(
|
|
|
1806
1824
|
expectedDestination: NativeExactFileIdentity | null,
|
|
1807
1825
|
): Promise<void> {
|
|
1808
1826
|
const contents = `${JSON.stringify(finalizeSidecarPayload(payload, keyId))}\n`;
|
|
1809
|
-
await writeCoordinatorAtomic(
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1827
|
+
await writeCoordinatorAtomic(
|
|
1828
|
+
stateFile,
|
|
1829
|
+
contents,
|
|
1830
|
+
coordinatorAtomicWriteOptionsForTests({
|
|
1831
|
+
rename: async (source: string, destination: string) => {
|
|
1832
|
+
if (!expectedDestination) {
|
|
1833
|
+
const published = renameNoReplacePath(source, destination);
|
|
1834
|
+
if (!published.ok) throw new PreviousRuntimeStateReadError();
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const stat = fsSync.lstatSync(source, { bigint: true });
|
|
1838
|
+
const parent = fsSync.lstatSync(path.dirname(source), { bigint: true });
|
|
1839
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !parent.isDirectory() || parent.isSymbolicLink())
|
|
1840
|
+
throw new PreviousRuntimeStateReadError();
|
|
1841
|
+
const replaced = exactReplacePath(
|
|
1842
|
+
source,
|
|
1843
|
+
destination,
|
|
1844
|
+
{
|
|
1845
|
+
dev: stat.dev,
|
|
1846
|
+
ino: stat.ino,
|
|
1847
|
+
nlink: stat.nlink,
|
|
1848
|
+
parentDev: parent.dev,
|
|
1849
|
+
parentIno: parent.ino,
|
|
1850
|
+
size: stat.size,
|
|
1851
|
+
mtimeNs: stat.mtimeNs,
|
|
1852
|
+
sha256: createHash("sha256").update(contents).digest("hex"),
|
|
1853
|
+
},
|
|
1854
|
+
expectedDestination,
|
|
1855
|
+
);
|
|
1856
|
+
if (!replaced.ok) throw new PreviousRuntimeStateReadError();
|
|
1857
|
+
},
|
|
1858
|
+
}),
|
|
1859
|
+
);
|
|
1838
1860
|
}
|
|
1839
1861
|
|
|
1840
1862
|
/**
|
|
@@ -1967,7 +1989,7 @@ async function withStateFileLocks<T>(stateFiles: readonly string[], operation: (
|
|
|
1967
1989
|
}
|
|
1968
1990
|
|
|
1969
1991
|
async function writeStateFile(stateFile: string, payload: Record<string, unknown>): Promise<void> {
|
|
1970
|
-
await writeCoordinatorAtomic(stateFile, `${JSON.stringify(payload)}\n
|
|
1992
|
+
await writeCoordinatorAtomic(stateFile, `${JSON.stringify(payload)}\n`, coordinatorAtomicWriteOptionsForTests());
|
|
1971
1993
|
}
|
|
1972
1994
|
|
|
1973
1995
|
function contextWithManagedOwnerGeneration(context: RuntimeStateContext): RuntimeStateContext {
|
|
@@ -2471,12 +2493,16 @@ export async function prepareCoordinatorRuntimeStateRescope(input: {
|
|
|
2471
2493
|
).toString("base64");
|
|
2472
2494
|
}
|
|
2473
2495
|
const writeJournal = async (journalFile: string): Promise<void> => {
|
|
2474
|
-
await writeCoordinatorAtomic(
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2496
|
+
await writeCoordinatorAtomic(
|
|
2497
|
+
journalFile,
|
|
2498
|
+
`${JSON.stringify(journal)}\n`,
|
|
2499
|
+
coordinatorAtomicWriteOptionsForTests({
|
|
2500
|
+
rename: async (source: string, destination: string) => {
|
|
2501
|
+
const published = renameNoReplacePath(source, destination);
|
|
2502
|
+
if (!published.ok) throw new PreviousRuntimeStateReadError();
|
|
2503
|
+
},
|
|
2504
|
+
}),
|
|
2505
|
+
);
|
|
2480
2506
|
};
|
|
2481
2507
|
await __sessionStateSidecarTestHooks.beforeRescopeJournalWrite?.(previousCwd);
|
|
2482
2508
|
assertPinnedDirectoryIdentity(input.previousCwd, previousCwdIdentity);
|
|
@@ -2532,28 +2558,32 @@ export async function markCoordinatorRuntimeStateRescopePublishing(
|
|
|
2532
2558
|
).toString("base64");
|
|
2533
2559
|
}
|
|
2534
2560
|
const contents = `${JSON.stringify(journal)}\n`;
|
|
2535
|
-
await writeCoordinatorAtomic(
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
source,
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2561
|
+
await writeCoordinatorAtomic(
|
|
2562
|
+
journalFile,
|
|
2563
|
+
contents,
|
|
2564
|
+
coordinatorAtomicWriteOptionsForTests({
|
|
2565
|
+
rename: async (source: string, destination: string) => {
|
|
2566
|
+
const sourceStat = fsSync.lstatSync(source, { bigint: true });
|
|
2567
|
+
const sourceParent = fsSync.lstatSync(path.dirname(source), { bigint: true });
|
|
2568
|
+
const replaced = exactReplacePath(
|
|
2569
|
+
source,
|
|
2570
|
+
destination,
|
|
2571
|
+
{
|
|
2572
|
+
dev: sourceStat.dev,
|
|
2573
|
+
ino: sourceStat.ino,
|
|
2574
|
+
nlink: sourceStat.nlink,
|
|
2575
|
+
parentDev: sourceParent.dev,
|
|
2576
|
+
parentIno: sourceParent.ino,
|
|
2577
|
+
size: sourceStat.size,
|
|
2578
|
+
mtimeNs: sourceStat.mtimeNs,
|
|
2579
|
+
sha256: createHash("sha256").update(contents).digest("hex"),
|
|
2580
|
+
},
|
|
2581
|
+
observed.identity,
|
|
2582
|
+
);
|
|
2583
|
+
if (!replaced.ok) throw new PreviousRuntimeStateReadError();
|
|
2584
|
+
},
|
|
2585
|
+
}),
|
|
2586
|
+
);
|
|
2557
2587
|
}
|
|
2558
2588
|
}
|
|
2559
2589
|
|