@gajae-code/coding-agent 0.10.0 → 0.10.2
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 +33 -0
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/policy.d.ts +3 -0
- package/dist/types/coordinator-mcp/server.d.ts +17 -5
- package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
- package/dist/types/extensibility/extensions/types.d.ts +3 -1
- package/dist/types/modes/components/gajae-pet-widget.d.ts +1 -0
- package/dist/types/modes/components/pet-capability.d.ts +37 -0
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/settings-selector.d.ts +9 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +0 -10
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/print-mode.d.ts +3 -4
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
- package/dist/types/modes/types.d.ts +11 -1
- package/dist/types/modes/utils/context-usage.d.ts +4 -19
- package/dist/types/session/agent-session.d.ts +14 -1
- package/dist/types/session/context-estimation.d.ts +31 -0
- package/package.json +7 -7
- package/src/cli.ts +1 -0
- package/src/config/model-profiles.ts +19 -19
- package/src/config/settings.ts +96 -45
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/policy.ts +30 -0
- package/src/coordinator-mcp/server.ts +294 -66
- package/src/coordinator-mcp/session-reaper.ts +129 -0
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/extensibility/slash-commands.ts +3 -3
- package/src/gjc-runtime/tmux-owner-isolation.ts +4 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/main.ts +3 -5
- package/src/modes/acp/acp-agent.ts +2 -2
- package/src/modes/components/custom-editor.ts +1 -0
- package/src/modes/components/footer.ts +3 -3
- package/src/modes/components/gajae-pet-widget.ts +169 -20
- package/src/modes/components/pet-capability.ts +115 -0
- package/src/modes/components/pet-selector.ts +4 -6
- package/src/modes/components/settings-selector.ts +28 -2
- package/src/modes/components/status-line/segments.ts +13 -6
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +4 -171
- package/src/modes/controllers/extension-ui-controller.ts +10 -9
- package/src/modes/controllers/input-controller.ts +43 -13
- package/src/modes/controllers/selector-controller.ts +7 -18
- package/src/modes/interactive-mode.ts +65 -4
- package/src/modes/print-mode.ts +228 -92
- package/src/modes/rpc/rpc-client.ts +58 -1
- package/src/modes/rpc/rpc-mode.ts +286 -85
- package/src/modes/rpc/rpc-types.ts +15 -1
- package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
- package/src/modes/shared/agent-wire/command-validation.ts +10 -0
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/types.ts +11 -1
- package/src/modes/utils/context-usage.ts +56 -92
- package/src/session/agent-session.ts +337 -90
- package/src/session/context-estimation.ts +91 -0
- package/src/session/session-manager.ts +9 -2
- package/src/slash-commands/builtin-registry.ts +31 -19
- package/src/slash-commands/helpers/context-report.ts +26 -4
|
@@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as nodeFs from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { isKnownSinkPeerClosedError } from "@gajae-code/utils";
|
|
5
6
|
import { VERSION } from "@gajae-code/utils/dirs";
|
|
7
|
+
|
|
6
8
|
import {
|
|
7
9
|
COORDINATOR_MCP_PROTOCOL_VERSION,
|
|
8
10
|
COORDINATOR_MCP_SERVER_NAME,
|
|
@@ -29,7 +31,7 @@ import {
|
|
|
29
31
|
replaceOwnerGenerationSync,
|
|
30
32
|
type TmuxServerProof,
|
|
31
33
|
} from "../gjc-runtime/tmux-owner-isolation";
|
|
32
|
-
|
|
34
|
+
import { forceCloseGjcTmuxSession } from "../gjc-runtime/tmux-sessions";
|
|
33
35
|
import {
|
|
34
36
|
type CoordinatorModelProfileLoader,
|
|
35
37
|
loadCoordinatorModelProfiles,
|
|
@@ -44,6 +46,7 @@ import {
|
|
|
44
46
|
coordinatorNamespacePath,
|
|
45
47
|
requireCoordinatorMutation,
|
|
46
48
|
} from "./policy";
|
|
49
|
+
import { createSessionReaper, type ReapableSession, type SessionReaper } from "./session-reaper";
|
|
47
50
|
|
|
48
51
|
export type { CoordinatorToolName };
|
|
49
52
|
export { COORDINATOR_MCP_PROTOCOL_VERSION, COORDINATOR_MCP_SERVER_NAME, COORDINATOR_MCP_TOOL_NAMES };
|
|
@@ -64,6 +67,16 @@ interface JsonRpcResponse {
|
|
|
64
67
|
error?: { code: number; message: string; data?: unknown };
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
function sinkErrorCode(error: unknown): string | undefined {
|
|
71
|
+
if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const code = Reflect.get(error, "code");
|
|
74
|
+
return typeof code === "string" ? code : undefined;
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
interface SessionStartInput {
|
|
68
81
|
cwd: string;
|
|
69
82
|
prompt?: string;
|
|
@@ -121,6 +134,12 @@ interface CoordinatorServices {
|
|
|
121
134
|
listSessions?: () => unknown[] | Promise<unknown[]>;
|
|
122
135
|
startSession?: (input: SessionStartInput) => unknown | Promise<unknown>;
|
|
123
136
|
commandRunner?: CommandRunner;
|
|
137
|
+
forceCloseSession?: (
|
|
138
|
+
sessionName: string,
|
|
139
|
+
env: NodeJS.ProcessEnv,
|
|
140
|
+
expectedSessionId?: string,
|
|
141
|
+
expectedStateFile?: string,
|
|
142
|
+
) => Promise<unknown>;
|
|
124
143
|
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
125
144
|
resolveModelProfiles?: CoordinatorModelProfileLoader;
|
|
126
145
|
}
|
|
@@ -240,6 +259,7 @@ interface CoordinatorSessionState {
|
|
|
240
259
|
type CoordinatorEventKind =
|
|
241
260
|
| "session.registered"
|
|
242
261
|
| "session.started"
|
|
262
|
+
| "session.reaped"
|
|
243
263
|
| "session.state_changed"
|
|
244
264
|
| "turn.queued"
|
|
245
265
|
| "turn.delivering"
|
|
@@ -380,6 +400,26 @@ function toolSchema(name: CoordinatorToolName): {
|
|
|
380
400
|
},
|
|
381
401
|
};
|
|
382
402
|
}
|
|
403
|
+
if (name === "gjc_coordinator_stop_session") {
|
|
404
|
+
return {
|
|
405
|
+
name,
|
|
406
|
+
description:
|
|
407
|
+
"Reap a coordinator delegate-created (ephemeral) session: terminate its owner via the owner-proof forceCloseGjcTmuxSession, then purge its state files only on verified termination. Never touches non-ephemeral (user-registered) sessions unless force is set AND the force-stop capability is enabled.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
type: "object",
|
|
410
|
+
properties: {
|
|
411
|
+
session_id: sessionId,
|
|
412
|
+
force: {
|
|
413
|
+
type: "boolean",
|
|
414
|
+
description: "Reap a non-ephemeral session; requires the GJC_COORDINATOR_MCP_FORCE_STOP capability.",
|
|
415
|
+
},
|
|
416
|
+
reason: { type: "string", description: "Optional audit reason recorded on the session.reaped event." },
|
|
417
|
+
allow_mutation: allowMutation,
|
|
418
|
+
},
|
|
419
|
+
required: ["session_id", "allow_mutation"],
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
383
423
|
if (name === "gjc_coordinator_send_prompt") {
|
|
384
424
|
return {
|
|
385
425
|
name,
|
|
@@ -868,6 +908,7 @@ const COORDINATOR_SESSION_STATES = new Set<CoordinatorSessionStateValue>([
|
|
|
868
908
|
const COORDINATOR_EVENT_KINDS = new Set<CoordinatorEventKind>([
|
|
869
909
|
"session.registered",
|
|
870
910
|
"session.started",
|
|
911
|
+
"session.reaped",
|
|
871
912
|
"session.state_changed",
|
|
872
913
|
"turn.queued",
|
|
873
914
|
"turn.delivering",
|
|
@@ -3284,6 +3325,103 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
3284
3325
|
function sessionFile(sessionId: unknown): string {
|
|
3285
3326
|
return path.join(namespaceDir, "sessions", `${safeExternalId("session", sessionId)}.json`);
|
|
3286
3327
|
}
|
|
3328
|
+
const forceCloseSession =
|
|
3329
|
+
services.forceCloseSession ??
|
|
3330
|
+
((name: string, targetEnv: NodeJS.ProcessEnv, expectedSessionId?: string, expectedStateFile?: string) =>
|
|
3331
|
+
forceCloseGjcTmuxSession(name, targetEnv, expectedSessionId, expectedStateFile));
|
|
3332
|
+
|
|
3333
|
+
// Reap a coordinator delegate-created (ephemeral) session. Addresses the #2034 review:
|
|
3334
|
+
// - terminates via the owner-proof forceCloseGjcTmuxSession (pid + native session_id +
|
|
3335
|
+
// generation + server_key + start_time verified atomically) — never a raw process.kill,
|
|
3336
|
+
// so a recycled PID can never signal a foreign process.
|
|
3337
|
+
// - re-validates ephemeral + no-active-turn AT kill time under a per-session lock, closing
|
|
3338
|
+
// the select→kill TOCTOU and races with concurrent send_prompt / delegate / stop.
|
|
3339
|
+
// - purges state files ONLY on verified termination; on failure the record is kept for retry
|
|
3340
|
+
// and no session.reaped event is emitted (honest audit, no worse leak).
|
|
3341
|
+
async function reapSession(
|
|
3342
|
+
rawId: unknown,
|
|
3343
|
+
opts: { force?: boolean; reason?: string } = {},
|
|
3344
|
+
): Promise<{ ok: boolean; reason?: string; killed: boolean; active_turn_id?: string; detail?: string }> {
|
|
3345
|
+
const id = safeExternalId("session", rawId);
|
|
3346
|
+
return await withSessionMutation(`${namespaceDir}::${id}`, async () => {
|
|
3347
|
+
const record = asRecord(await readJsonFile(sessionFile(id)));
|
|
3348
|
+
if (!record) return { ok: false, reason: "unknown_session", killed: false };
|
|
3349
|
+
if (record.ephemeral !== true && opts.force !== true) {
|
|
3350
|
+
return { ok: false, reason: "not_ephemeral", killed: false };
|
|
3351
|
+
}
|
|
3352
|
+
const activeTurn = await readActiveTurn(namespaceDir, id);
|
|
3353
|
+
if (activeTurn) {
|
|
3354
|
+
return { ok: false, reason: "active_turn", killed: false, active_turn_id: activeTurn.turn_id };
|
|
3355
|
+
}
|
|
3356
|
+
const tmuxSession = optionalString(record.tmux_session) ?? optionalString(record.tmuxSession);
|
|
3357
|
+
let killed = false;
|
|
3358
|
+
if (tmuxSession) {
|
|
3359
|
+
// Production delegate records persist the owner state-file path as `runtimeStateFile`
|
|
3360
|
+
// (see startTmuxSession). Read that so the owner-proof close is actually bound to the
|
|
3361
|
+
// recorded state-file identity; `sessionStateFile` is only a compatibility fallback for
|
|
3362
|
+
// any externally-registered record that used the older key.
|
|
3363
|
+
const expectedStateFile =
|
|
3364
|
+
optionalString(record.runtimeStateFile) ?? optionalString(record.sessionStateFile) ?? undefined;
|
|
3365
|
+
try {
|
|
3366
|
+
await forceCloseSession(tmuxSession, env, undefined, expectedStateFile);
|
|
3367
|
+
killed = true;
|
|
3368
|
+
} catch (err) {
|
|
3369
|
+
return {
|
|
3370
|
+
ok: false,
|
|
3371
|
+
reason: "terminate_failed",
|
|
3372
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
3373
|
+
killed: false,
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
// Purge the session record FIRST so a partial rm failure can never re-list → re-reap →
|
|
3378
|
+
// emit a duplicate session.reaped. `killed` is honest: false when nothing was signalled.
|
|
3379
|
+
await fs.rm(sessionFile(id), { force: true });
|
|
3380
|
+
await appendCoordinatorEvent(namespaceDir, {
|
|
3381
|
+
kind: "session.reaped",
|
|
3382
|
+
sessionId: id,
|
|
3383
|
+
summary: `Session ${id} reaped${opts.reason ? ` (${opts.reason})` : ""}`,
|
|
3384
|
+
metadata: { reason: opts.reason ?? null, force: opts.force === true, killed },
|
|
3385
|
+
});
|
|
3386
|
+
await fs.rm(sessionStateFile(namespaceDir, id), { force: true });
|
|
3387
|
+
await fs.rm(activeTurnFile(namespaceDir, id), { force: true });
|
|
3388
|
+
return { ok: true, killed };
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
const sessionReaper: SessionReaper = createSessionReaper(
|
|
3393
|
+
{
|
|
3394
|
+
listSessions: async (): Promise<ReapableSession[]> => {
|
|
3395
|
+
const out: ReapableSession[] = [];
|
|
3396
|
+
for (const raw of await listSessions()) {
|
|
3397
|
+
try {
|
|
3398
|
+
const rec = asRecord(raw);
|
|
3399
|
+
if (rec?.ephemeral !== true) continue;
|
|
3400
|
+
const sid = optionalString(rec.session_id);
|
|
3401
|
+
if (!sid) continue;
|
|
3402
|
+
const activeTurn = await readActiveTurn(namespaceDir, sid);
|
|
3403
|
+
const state = await readSessionState(namespaceDir, sid);
|
|
3404
|
+
const stamp = optionalString(state?.updated_at) ?? optionalString(rec.created_at);
|
|
3405
|
+
const lastActivityMs = stamp ? Date.parse(stamp) : 0;
|
|
3406
|
+
out.push({
|
|
3407
|
+
sessionId: sid,
|
|
3408
|
+
ephemeral: true,
|
|
3409
|
+
hasActiveTurn: activeTurn !== null,
|
|
3410
|
+
lastActivityMs: Number.isFinite(lastActivityMs) ? lastActivityMs : 0,
|
|
3411
|
+
});
|
|
3412
|
+
} catch {
|
|
3413
|
+
// A single corrupt session/state file must not abort the whole sweep — skip it.
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
return out;
|
|
3417
|
+
},
|
|
3418
|
+
reapSession: async (sid: string): Promise<void> => {
|
|
3419
|
+
await reapSession(sid, { reason: "idle_reaper" });
|
|
3420
|
+
},
|
|
3421
|
+
now: () => Date.now(),
|
|
3422
|
+
},
|
|
3423
|
+
{ idleTtlMs: config.sessionIdleTtlMs, sweepIntervalMs: config.sessionSweepIntervalMs },
|
|
3424
|
+
);
|
|
3287
3425
|
async function compensateFailedOwnerStart(
|
|
3288
3426
|
session: Record<string, unknown>,
|
|
3289
3427
|
ownerTransaction: {
|
|
@@ -4024,6 +4162,9 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
4024
4162
|
commit?: () => void;
|
|
4025
4163
|
rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
|
|
4026
4164
|
} | null;
|
|
4165
|
+
// Delegate-created sessions are ephemeral: the idle reaper may reclaim them once
|
|
4166
|
+
// idle past the TTL with no active turn. User-registered sessions are never flagged.
|
|
4167
|
+
session.ephemeral = true;
|
|
4027
4168
|
try {
|
|
4028
4169
|
if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
|
|
4029
4170
|
await writeJsonFile(sessionFile(session.session_id), session);
|
|
@@ -4134,6 +4275,28 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
4134
4275
|
}
|
|
4135
4276
|
return base;
|
|
4136
4277
|
}
|
|
4278
|
+
if (name === "gjc_coordinator_stop_session") {
|
|
4279
|
+
requireCoordinatorMutation(config, "sessions", args);
|
|
4280
|
+
const sessionId = safeExternalId("session", args.session_id);
|
|
4281
|
+
const forceRequested = args.force === true;
|
|
4282
|
+
// force is a capability distinct from allow_mutation: reaping a non-ephemeral
|
|
4283
|
+
// (user-registered) session requires GJC_COORDINATOR_MCP_FORCE_STOP to be enabled.
|
|
4284
|
+
if (forceRequested && !config.forceStopEnabled) {
|
|
4285
|
+
return { ok: false, reason: "force_not_authorized", session_id: sessionId, killed: false };
|
|
4286
|
+
}
|
|
4287
|
+
const result = await reapSession(sessionId, {
|
|
4288
|
+
force: forceRequested,
|
|
4289
|
+
reason: optionalString(args.reason) ?? "stop_session",
|
|
4290
|
+
});
|
|
4291
|
+
return {
|
|
4292
|
+
ok: result.ok,
|
|
4293
|
+
session_id: sessionId,
|
|
4294
|
+
killed: result.killed,
|
|
4295
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
4296
|
+
...(result.active_turn_id ? { active_turn_id: result.active_turn_id } : {}),
|
|
4297
|
+
...(result.detail ? { detail: result.detail } : {}),
|
|
4298
|
+
};
|
|
4299
|
+
}
|
|
4137
4300
|
if (name === "gjc_coordinator_start_session") {
|
|
4138
4301
|
requireCoordinatorMutation(config, "sessions", args);
|
|
4139
4302
|
const cwd = await assertCoordinatorWorkdir(config, args.cwd);
|
|
@@ -4592,7 +4755,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
|
|
|
4592
4755
|
};
|
|
4593
4756
|
}
|
|
4594
4757
|
|
|
4595
|
-
return { config, callTool, handleJsonRpc, handle: handleJsonRpc };
|
|
4758
|
+
return { config, callTool, handleJsonRpc, handle: handleJsonRpc, reapSession, sessionReaper };
|
|
4596
4759
|
}
|
|
4597
4760
|
|
|
4598
4761
|
function legacyToolResult(payload: unknown): {
|
|
@@ -4678,11 +4841,10 @@ export interface PumpCoordinatorOptions {
|
|
|
4678
4841
|
* answerable even while data handlers saturate.
|
|
4679
4842
|
* - Data handlers are capped at `maxDataConcurrency`; excess is queued up to
|
|
4680
4843
|
* `maxQueueDepth`, then rejected as `server_busy` (bounded memory / fanout).
|
|
4681
|
-
* - `
|
|
4682
|
-
*
|
|
4683
|
-
*
|
|
4684
|
-
*
|
|
4685
|
-
* flushes queued writes before returning, so shutdown never races live work.
|
|
4844
|
+
* - A coded local `EPIPE` terminalizes writer and dispatch together; other
|
|
4845
|
+
* writer faults reject the pump without poisoning the serialized write chain.
|
|
4846
|
+
* - On EOF or a closed peer the pump drains already-running handlers (bounded
|
|
4847
|
+
* by `drainTimeoutMs`) and never promotes queued work.
|
|
4686
4848
|
* - Byte chunks are decoded with a streaming decoder so multibyte characters
|
|
4687
4849
|
* split across chunks are not corrupted.
|
|
4688
4850
|
*/
|
|
@@ -4694,45 +4856,81 @@ export async function pumpCoordinatorMcpStream(
|
|
|
4694
4856
|
): Promise<void> {
|
|
4695
4857
|
const maxDataConcurrency = Math.max(1, options.maxDataConcurrency ?? 32);
|
|
4696
4858
|
const maxQueueDepth = Math.max(0, options.maxQueueDepth ?? 256);
|
|
4697
|
-
const drainTimeoutMs = Math.max(
|
|
4859
|
+
const drainTimeoutMs = Math.max(1, options.drainTimeoutMs ?? 30_000);
|
|
4698
4860
|
|
|
4699
|
-
let
|
|
4861
|
+
let writerState: "open" | "terminalizing" | "closed" = "open";
|
|
4700
4862
|
let draining = false;
|
|
4701
4863
|
let writeChain: Promise<void> = Promise.resolve();
|
|
4702
4864
|
const inFlight = new Set<Promise<void>>();
|
|
4703
4865
|
let activeData = 0;
|
|
4704
4866
|
const dataQueue: JsonRpcRequest[] = [];
|
|
4867
|
+
const peerClosed = Promise.withResolvers<void>();
|
|
4868
|
+
const writerFailure = Promise.withResolvers<never>();
|
|
4869
|
+
void writerFailure.promise.catch(() => {});
|
|
4870
|
+
const inputIterator = input[Symbol.asyncIterator]();
|
|
4871
|
+
let inputDetached = false;
|
|
4872
|
+
let fatalWriterFailure: { value: unknown } | undefined;
|
|
4873
|
+
|
|
4874
|
+
const detachInput = (): void => {
|
|
4875
|
+
if (inputDetached) return;
|
|
4876
|
+
inputDetached = true;
|
|
4877
|
+
const detached = inputIterator.return?.();
|
|
4878
|
+
if (detached) void detached.catch(() => {});
|
|
4879
|
+
};
|
|
4880
|
+
const terminalizePeer = (): void => {
|
|
4881
|
+
if (writerState !== "open") return;
|
|
4882
|
+
writerState = "terminalizing";
|
|
4883
|
+
draining = true;
|
|
4884
|
+
dataQueue.length = 0;
|
|
4885
|
+
detachInput();
|
|
4886
|
+
peerClosed.resolve();
|
|
4887
|
+
};
|
|
4888
|
+
const failWriter = (failure: unknown): void => {
|
|
4889
|
+
if (writerState === "closed") return;
|
|
4890
|
+
writerState = "closed";
|
|
4891
|
+
draining = true;
|
|
4892
|
+
dataQueue.length = 0;
|
|
4893
|
+
detachInput();
|
|
4894
|
+
fatalWriterFailure = { value: failure };
|
|
4895
|
+
|
|
4896
|
+
writerFailure.reject(failure);
|
|
4897
|
+
};
|
|
4898
|
+
const isExpectedPeerClosure = (failure: unknown): boolean =>
|
|
4899
|
+
isKnownSinkPeerClosedError(failure) && sinkErrorCode(failure) === "EPIPE";
|
|
4705
4900
|
|
|
4706
|
-
const emit = (response: JsonRpcResponse): void => {
|
|
4901
|
+
const emit = (response: JsonRpcResponse): Promise<void> => {
|
|
4707
4902
|
writeChain = writeChain.then(async () => {
|
|
4708
|
-
if (
|
|
4903
|
+
if (writerState !== "open") return;
|
|
4709
4904
|
try {
|
|
4710
4905
|
await writeLine(`${JSON.stringify(response)}\n`);
|
|
4711
|
-
} catch {
|
|
4712
|
-
|
|
4906
|
+
} catch (failure) {
|
|
4907
|
+
if (isExpectedPeerClosure(failure)) terminalizePeer();
|
|
4908
|
+
else failWriter(failure);
|
|
4713
4909
|
}
|
|
4714
4910
|
});
|
|
4911
|
+
return writeChain;
|
|
4715
4912
|
};
|
|
4716
4913
|
|
|
4717
4914
|
const launch = (request: JsonRpcRequest, control: boolean): void => {
|
|
4718
4915
|
const task = (async () => {
|
|
4916
|
+
let response: JsonRpcResponse;
|
|
4719
4917
|
try {
|
|
4720
|
-
|
|
4721
|
-
} catch (
|
|
4722
|
-
|
|
4918
|
+
response = await handleJsonRpc(request);
|
|
4919
|
+
} catch (failure) {
|
|
4920
|
+
response = {
|
|
4723
4921
|
jsonrpc: "2.0",
|
|
4724
4922
|
id: request.id ?? null,
|
|
4725
|
-
error: { code: -32603, message: publicCoordinatorError(
|
|
4726
|
-
}
|
|
4727
|
-
}
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4923
|
+
error: { code: -32603, message: publicCoordinatorError(failure) },
|
|
4924
|
+
};
|
|
4925
|
+
}
|
|
4926
|
+
await emit(response);
|
|
4927
|
+
if (!control) {
|
|
4928
|
+
activeData -= 1;
|
|
4929
|
+
if (!draining && writerState === "open") {
|
|
4930
|
+
const next = dataQueue.shift();
|
|
4931
|
+
if (next) {
|
|
4932
|
+
activeData += 1;
|
|
4933
|
+
launch(next, false);
|
|
4736
4934
|
}
|
|
4737
4935
|
}
|
|
4738
4936
|
}
|
|
@@ -4742,6 +4940,7 @@ export async function pumpCoordinatorMcpStream(
|
|
|
4742
4940
|
};
|
|
4743
4941
|
|
|
4744
4942
|
const dispatch = (request: JsonRpcRequest): void => {
|
|
4943
|
+
if (writerState !== "open") return;
|
|
4745
4944
|
// Notifications (no id) get no response; the coordinator has no side-effecting ones.
|
|
4746
4945
|
if (request.id === undefined || request.id === null) return;
|
|
4747
4946
|
if (request.method === "ping") {
|
|
@@ -4757,61 +4956,90 @@ export async function pumpCoordinatorMcpStream(
|
|
|
4757
4956
|
dataQueue.push(request);
|
|
4758
4957
|
return;
|
|
4759
4958
|
}
|
|
4760
|
-
emit({
|
|
4959
|
+
void emit({
|
|
4761
4960
|
jsonrpc: "2.0",
|
|
4762
4961
|
id: request.id,
|
|
4763
4962
|
error: { code: -32000, message: "server_busy: coordinator request queue is full" },
|
|
4764
4963
|
});
|
|
4765
4964
|
};
|
|
4766
4965
|
|
|
4966
|
+
const drainInFlightAndWrites = async (): Promise<void> => {
|
|
4967
|
+
let timer: NodeJS.Timeout | undefined;
|
|
4968
|
+
const timeout = new Promise<"timed_out">(resolve => {
|
|
4969
|
+
timer = setTimeout(() => resolve("timed_out"), drainTimeoutMs);
|
|
4970
|
+
(timer as { unref?: () => void }).unref?.();
|
|
4971
|
+
});
|
|
4972
|
+
const drain = async (): Promise<"drained"> => {
|
|
4973
|
+
while (true) {
|
|
4974
|
+
if (inFlight.size > 0) await Promise.allSettled([...inFlight]);
|
|
4975
|
+
const writes = writeChain;
|
|
4976
|
+
await writes;
|
|
4977
|
+
if (inFlight.size === 0 && writes === writeChain) return "drained";
|
|
4978
|
+
}
|
|
4979
|
+
};
|
|
4980
|
+
try {
|
|
4981
|
+
if ((await Promise.race([drain(), timeout])) === "timed_out") {
|
|
4982
|
+
writerState = "closed";
|
|
4983
|
+
draining = true;
|
|
4984
|
+
dataQueue.length = 0;
|
|
4985
|
+
detachInput();
|
|
4986
|
+
}
|
|
4987
|
+
} finally {
|
|
4988
|
+
if (timer) clearTimeout(timer);
|
|
4989
|
+
}
|
|
4990
|
+
};
|
|
4991
|
+
|
|
4767
4992
|
const decoder = new TextDecoder();
|
|
4768
4993
|
let buffer = "";
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
buffer
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
request = null;
|
|
4994
|
+
let inputFailure: unknown;
|
|
4995
|
+
try {
|
|
4996
|
+
while (writerState === "open") {
|
|
4997
|
+
const next = await Promise.race([inputIterator.next(), peerClosed.promise, writerFailure.promise]);
|
|
4998
|
+
if (!next || next.done) break;
|
|
4999
|
+
buffer += typeof next.value === "string" ? next.value : decoder.decode(next.value, { stream: true });
|
|
5000
|
+
let newline = buffer.indexOf("\n");
|
|
5001
|
+
while (newline >= 0 && writerState === "open") {
|
|
5002
|
+
const line = buffer.slice(0, newline).trim();
|
|
5003
|
+
buffer = buffer.slice(newline + 1);
|
|
5004
|
+
if (line.length > 0) {
|
|
5005
|
+
let request: JsonRpcRequest | null = null;
|
|
5006
|
+
try {
|
|
5007
|
+
request = JSON.parse(line) as JsonRpcRequest;
|
|
5008
|
+
} catch {
|
|
5009
|
+
request = null; // ignore malformed frames rather than crashing the loop
|
|
5010
|
+
}
|
|
5011
|
+
if (request) dispatch(request);
|
|
4781
5012
|
}
|
|
4782
|
-
|
|
5013
|
+
newline = buffer.indexOf("\n");
|
|
4783
5014
|
}
|
|
4784
|
-
newline = buffer.indexOf("\n");
|
|
4785
5015
|
}
|
|
5016
|
+
} catch (failure) {
|
|
5017
|
+
inputFailure = failure;
|
|
4786
5018
|
}
|
|
4787
5019
|
|
|
4788
|
-
// EOF
|
|
5020
|
+
// Input EOF or a closed peer stops promotion. One deadline bounds both the
|
|
5021
|
+
// active handlers and the serialized writes they have already queued.
|
|
4789
5022
|
draining = true;
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
timer = setTimeout(resolve, drainTimeoutMs);
|
|
4796
|
-
(timer as { unref?: () => void }).unref?.();
|
|
4797
|
-
});
|
|
4798
|
-
await Promise.race([drain, timeout]);
|
|
4799
|
-
if (timer) clearTimeout(timer);
|
|
4800
|
-
} else {
|
|
4801
|
-
await drain;
|
|
4802
|
-
}
|
|
4803
|
-
}
|
|
4804
|
-
await writeChain;
|
|
5023
|
+
dataQueue.length = 0;
|
|
5024
|
+
await drainInFlightAndWrites();
|
|
5025
|
+
if (fatalWriterFailure) throw fatalWriterFailure.value;
|
|
5026
|
+
if (inputFailure !== undefined) throw inputFailure;
|
|
5027
|
+
writerState = "closed";
|
|
4805
5028
|
}
|
|
4806
5029
|
|
|
4807
5030
|
export async function runCoordinatorMcpStdio(options: CoordinatorMcpServerOptions = {}): Promise<void> {
|
|
4808
5031
|
const server = createCoordinatorMcpServer(options);
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
5032
|
+
server.sessionReaper.start(); // background idle-reap of ephemeral delegate sessions
|
|
5033
|
+
try {
|
|
5034
|
+
await pumpCoordinatorMcpStream(
|
|
5035
|
+
request => server.handleJsonRpc(request),
|
|
5036
|
+
process.stdin,
|
|
5037
|
+
line =>
|
|
5038
|
+
new Promise<void>((resolve, reject) => {
|
|
5039
|
+
process.stdout.write(line, err => (err ? reject(err) : resolve()));
|
|
5040
|
+
}),
|
|
5041
|
+
);
|
|
5042
|
+
} finally {
|
|
5043
|
+
server.sessionReaper.stop();
|
|
5044
|
+
}
|
|
4817
5045
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { logger } from "@gajae-code/utils";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Idle reaper for coordinator-managed GJC worker sessions.
|
|
5
|
+
*
|
|
6
|
+
* Every `gjc_delegate_*` call that omits `session_id` starts a fresh tmux worker
|
|
7
|
+
* session. Nothing in the coordinator ever tore those down, so completed/crashed
|
|
8
|
+
* sessions accumulated (RAM + worktrees) until something killed them by hand.
|
|
9
|
+
*
|
|
10
|
+
* This is the automatic backstop (defense-in-depth): a periodic sweep force-closes
|
|
11
|
+
* sessions that are (a) ephemeral — coordinator delegate-created, never a user's
|
|
12
|
+
* registered resident session, (b) not mid-turn, and (c) idle past a TTL.
|
|
13
|
+
*
|
|
14
|
+
* The controller is pure + fully injectable (clock / list / reap side-effects) so
|
|
15
|
+
* it is unit-testable without tmux, the filesystem, or wall-clock waits. The
|
|
16
|
+
* scheduling mirrors the proven resource-gc controller: a recursive setTimeout
|
|
17
|
+
* with a generation guard so a stop()+start() can never leak a duplicate timer.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SESSION_IDLE_TTL_MS = 30 * 60_000; // 30 min idle → reap
|
|
21
|
+
export const DEFAULT_SESSION_SWEEP_INTERVAL_MS = 5 * 60_000; // sweep every 5 min
|
|
22
|
+
export const MIN_SESSION_IDLE_TTL_MS = 60_000; // never reap a <1min-idle session
|
|
23
|
+
export const MIN_SESSION_SWEEP_INTERVAL_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
/** Minimal projection of a coordinator session the reaper needs to decide. */
|
|
26
|
+
export interface ReapableSession {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
/** True only for coordinator delegate-created sessions. User-registered resident sessions are false and never reaped. */
|
|
29
|
+
ephemeral: boolean;
|
|
30
|
+
/** Epoch ms of last observed activity (turn update / session-state write / session creation). */
|
|
31
|
+
lastActivityMs: number;
|
|
32
|
+
/** True while a turn is active — never reap mid-turn. */
|
|
33
|
+
hasActiveTurn: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SessionReaperPolicy {
|
|
37
|
+
idleTtlMs: number;
|
|
38
|
+
sweepIntervalMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pure selection: which sessions are safe to reap at `now`.
|
|
43
|
+
* A session is reapable iff ephemeral AND not mid-turn AND idle ≥ (clamped) TTL.
|
|
44
|
+
*/
|
|
45
|
+
export function selectReapableSessions(
|
|
46
|
+
sessions: readonly ReapableSession[],
|
|
47
|
+
now: number,
|
|
48
|
+
idleTtlMs: number,
|
|
49
|
+
): ReapableSession[] {
|
|
50
|
+
const ttl = Math.max(MIN_SESSION_IDLE_TTL_MS, idleTtlMs);
|
|
51
|
+
return sessions.filter(s => s.ephemeral && !s.hasActiveTurn && now - s.lastActivityMs >= ttl);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Injectable side-effects so the controller runs in tests without tmux/fs/real time. */
|
|
55
|
+
export interface SessionReaperDeps {
|
|
56
|
+
listSessions: () => Promise<ReapableSession[]>;
|
|
57
|
+
reapSession: (sessionId: string) => Promise<void>;
|
|
58
|
+
now: () => number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SessionReaper {
|
|
62
|
+
/** Run one sweep; returns the number of sessions successfully reaped. */
|
|
63
|
+
sweepOnce: () => Promise<number>;
|
|
64
|
+
start: () => void;
|
|
65
|
+
stop: () => void;
|
|
66
|
+
readonly running: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createSessionReaper(deps: SessionReaperDeps, policy: SessionReaperPolicy): SessionReaper {
|
|
70
|
+
const idleTtlMs = Math.max(MIN_SESSION_IDLE_TTL_MS, policy.idleTtlMs);
|
|
71
|
+
const sweepIntervalMs = Math.max(MIN_SESSION_SWEEP_INTERVAL_MS, policy.sweepIntervalMs);
|
|
72
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
73
|
+
let generation = 0;
|
|
74
|
+
let inProgress = false;
|
|
75
|
+
|
|
76
|
+
async function sweepOnce(): Promise<number> {
|
|
77
|
+
if (inProgress) return 0; // never overlap sweeps
|
|
78
|
+
inProgress = true;
|
|
79
|
+
try {
|
|
80
|
+
const sessions = await deps.listSessions();
|
|
81
|
+
const targets = selectReapableSessions(sessions, deps.now(), idleTtlMs);
|
|
82
|
+
let reaped = 0;
|
|
83
|
+
for (const session of targets) {
|
|
84
|
+
try {
|
|
85
|
+
await deps.reapSession(session.sessionId);
|
|
86
|
+
reaped += 1;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// One wedged session must not abort the rest of the sweep.
|
|
89
|
+
logger.warn(
|
|
90
|
+
`session-reaper: failed to reap ${session.sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return reaped;
|
|
95
|
+
} finally {
|
|
96
|
+
inProgress = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function schedule(gen: number): void {
|
|
101
|
+
timer = setTimeout(() => {
|
|
102
|
+
if (gen !== generation) return; // stale timer from a prior start()
|
|
103
|
+
void sweepOnce().finally(() => {
|
|
104
|
+
if (gen === generation) schedule(gen);
|
|
105
|
+
});
|
|
106
|
+
}, sweepIntervalMs);
|
|
107
|
+
// The reaper must never keep the coordinator process alive by itself.
|
|
108
|
+
(timer as { unref?: () => void }).unref?.();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
sweepOnce,
|
|
113
|
+
start(): void {
|
|
114
|
+
if (timer) return; // already running
|
|
115
|
+
generation += 1;
|
|
116
|
+
schedule(generation);
|
|
117
|
+
},
|
|
118
|
+
stop(): void {
|
|
119
|
+
generation += 1; // invalidate any in-flight scheduled tick
|
|
120
|
+
if (timer) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
timer = null;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
get running(): boolean {
|
|
126
|
+
return timer !== null;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -484,7 +484,7 @@ export class ExtensionRunner {
|
|
|
484
484
|
abort: () => this.#abortFn(),
|
|
485
485
|
hasPendingMessages: () => this.#hasPendingMessagesFn(),
|
|
486
486
|
shutdown: () => this.#shutdownHandler(),
|
|
487
|
-
getSystemPrompt: () => this.#getSystemPromptFn(),
|
|
487
|
+
getSystemPrompt: () => [...this.#getSystemPromptFn()],
|
|
488
488
|
hasQueuedMessages: () => this.#hasPendingMessagesFn(), // deprecated alias
|
|
489
489
|
workflowGate: this.#getWorkflowGateFn(),
|
|
490
490
|
};
|
|
@@ -273,11 +273,13 @@ export interface ExtensionUIContext {
|
|
|
273
273
|
// ============================================================================
|
|
274
274
|
|
|
275
275
|
export interface ContextUsage {
|
|
276
|
-
/**
|
|
276
|
+
/** Context tokens, or null if unknown (e.g. right after compaction, before next LLM response). */
|
|
277
277
|
tokens: number | null;
|
|
278
278
|
contextWindow: number;
|
|
279
279
|
/** Context usage as percentage of context window, or null if tokens is unknown. */
|
|
280
280
|
percent: number | null;
|
|
281
|
+
/** Provenance: "provider_anchor" = anchored on provider-reported usage (+ heuristic trailing delta); "heuristic" = no provider anchor available, full estimate; "unknown" = post-compaction, no post-compaction provider response yet. */
|
|
282
|
+
source: "provider_anchor" | "heuristic" | "unknown";
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
export interface CompactOptions {
|