@gajae-code/coding-agent 0.5.0 → 0.5.1

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/async/job-manager.d.ts +26 -0
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/list-models.d.ts +6 -0
  5. package/dist/types/commands/gc.d.ts +26 -0
  6. package/dist/types/config/file-lock-gc.d.ts +5 -0
  7. package/dist/types/config/file-lock.d.ts +7 -0
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  10. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  11. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  12. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  13. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  14. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  15. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  16. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  19. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  22. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  23. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  25. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  26. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  27. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  28. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  29. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  30. package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
  31. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  32. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  33. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  34. package/dist/types/harness-control-plane/owner.d.ts +7 -0
  35. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  36. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  37. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  38. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  39. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  40. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  41. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  42. package/dist/types/session/agent-session.d.ts +1 -1
  43. package/dist/types/session/blob-store.d.ts +39 -3
  44. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  45. package/dist/types/tools/ask.d.ts +15 -1
  46. package/dist/types/tools/subagent.d.ts +6 -0
  47. package/package.json +7 -7
  48. package/src/async/job-manager.ts +52 -0
  49. package/src/cli/args.ts +3 -0
  50. package/src/cli/auth-broker-cli.ts +1 -0
  51. package/src/cli/list-models.ts +13 -1
  52. package/src/cli.ts +1 -0
  53. package/src/commands/gc.ts +22 -0
  54. package/src/commands/harness.ts +7 -3
  55. package/src/config/file-lock-gc.ts +181 -0
  56. package/src/config/file-lock.ts +14 -0
  57. package/src/config/model-profiles.ts +24 -15
  58. package/src/coordinator/contract.ts +1 -0
  59. package/src/coordinator-mcp/server.ts +459 -3
  60. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  61. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  62. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  63. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  64. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  65. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  66. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  67. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  68. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  69. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  70. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  71. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  72. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  75. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  76. package/src/defaults/gjc-defaults.ts +7 -0
  77. package/src/defaults/gjc-grok-cli.ts +22 -0
  78. package/src/extensibility/extensions/index.ts +1 -0
  79. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  80. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  81. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  82. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  83. package/src/gjc-runtime/gc-render.ts +70 -0
  84. package/src/gjc-runtime/gc-runtime.ts +403 -0
  85. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  86. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  87. package/src/gjc-runtime/state-renderer.ts +12 -3
  88. package/src/gjc-runtime/state-runtime.ts +46 -29
  89. package/src/gjc-runtime/team-gc.ts +49 -0
  90. package/src/gjc-runtime/team-runtime.ts +179 -2
  91. package/src/gjc-runtime/tmux-common.ts +14 -0
  92. package/src/gjc-runtime/tmux-gc.ts +176 -0
  93. package/src/gjc-runtime/tmux-sessions.ts +49 -1
  94. package/src/gjc-runtime/ultragoal-runtime.ts +12 -0
  95. package/src/harness-control-plane/gc-adapter.ts +184 -0
  96. package/src/harness-control-plane/owner.ts +11 -0
  97. package/src/harness-control-plane/storage.ts +70 -0
  98. package/src/internal-urls/docs-index.generated.ts +14 -8
  99. package/src/main.ts +7 -2
  100. package/src/modes/components/hook-selector.ts +19 -0
  101. package/src/modes/components/model-selector.ts +25 -8
  102. package/src/modes/components/status-line/segments.ts +1 -1
  103. package/src/modes/controllers/command-controller.ts +25 -6
  104. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  105. package/src/modes/controllers/selector-controller.ts +1 -0
  106. package/src/modes/rpc/rpc-mode.ts +151 -33
  107. package/src/modes/shared/agent-wire/command-dispatch.ts +278 -261
  108. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  109. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  110. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  111. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  112. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  113. package/src/sdk.ts +17 -3
  114. package/src/session/agent-session.ts +77 -8
  115. package/src/session/blob-store.ts +59 -3
  116. package/src/session/session-manager.ts +4 -4
  117. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  118. package/src/skill-state/workflow-hud.ts +106 -10
  119. package/src/slash-commands/builtin-registry.ts +3 -2
  120. package/src/task/executor.ts +9 -0
  121. package/src/tools/ask.ts +56 -1
  122. package/src/tools/job.ts +3 -2
  123. package/src/tools/monitor.ts +36 -1
  124. package/src/tools/subagent-render.ts +9 -0
  125. package/src/tools/subagent.ts +26 -2
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Cross-process registry of running gjc RPC sessions (issue 10).
3
+ *
4
+ * Each live RPC server writes a record under `<agent-dir>/rpc-sessions/<id>.json`
5
+ * on start and removes it on shutdown, so a separate process can discover which
6
+ * sessions are alive (and, once persistence lands in issue 09, how to reach
7
+ * them). Listing reaps records whose owning process is no longer alive, so a
8
+ * crashed server never leaves a permanent phantom entry.
9
+ */
10
+ import * as fs from "node:fs/promises";
11
+ import * as path from "node:path";
12
+ import { getAgentDir } from "@gajae-code/utils";
13
+
14
+ export type RpcSessionTransport = "stdio" | "bridge" | "socket";
15
+
16
+ export interface RpcSessionRecord {
17
+ sessionId: string;
18
+ pid: number;
19
+ transport: RpcSessionTransport;
20
+ cwd: string;
21
+ model?: string;
22
+ /** ISO-8601 start timestamp. */
23
+ startedAt: string;
24
+ /** Reachable endpoint for persistent transports (issue 09); absent for stdio. */
25
+ endpoint?: string;
26
+ }
27
+
28
+ /** Registry directory: `<agent-dir>/rpc-sessions` (honors GJC_CODING_AGENT_DIR via getAgentDir). */
29
+ function rpcSessionsDir(agentDir?: string): string {
30
+ return path.join(agentDir ?? getAgentDir(), "rpc-sessions");
31
+ }
32
+
33
+ function recordPath(sessionId: string, agentDir?: string): string {
34
+ return path.join(rpcSessionsDir(agentDir), `${sessionId}.json`);
35
+ }
36
+
37
+ /**
38
+ * Write (or replace) the registry record for a session. The record is written to
39
+ * a same-directory temp file and atomically renamed into place so a concurrent
40
+ * reader never observes (and reaps) a partially-written record.
41
+ */
42
+ export async function registerRpcSession(record: RpcSessionRecord, agentDir?: string): Promise<string> {
43
+ const file = recordPath(record.sessionId, agentDir);
44
+ // `.tmp` suffix keeps the staging file out of the `*.json` listing/reaping path.
45
+ const staging = `${file}.${process.pid}.tmp`;
46
+ await Bun.write(staging, JSON.stringify(record));
47
+ await fs.rename(staging, file);
48
+ return file;
49
+ }
50
+
51
+ /** Remove a session's registry record. Best-effort: a missing file is not an error. */
52
+ export async function unregisterRpcSession(sessionId: string, agentDir?: string): Promise<void> {
53
+ await fs.rm(recordPath(sessionId, agentDir), { force: true });
54
+ }
55
+
56
+ function isProcessAlive(pid: number): boolean {
57
+ if (!Number.isInteger(pid) || pid <= 0) return false;
58
+ try {
59
+ // Signal 0 performs error checking without delivering a signal.
60
+ process.kill(pid, 0);
61
+ return true;
62
+ } catch (err) {
63
+ // ESRCH => no such process (dead). EPERM => alive but owned by another user.
64
+ return (err as NodeJS.ErrnoException).code === "EPERM";
65
+ }
66
+ }
67
+
68
+ function parseRecord(raw: string): RpcSessionRecord | undefined {
69
+ let obj: Partial<RpcSessionRecord>;
70
+ try {
71
+ obj = JSON.parse(raw) as Partial<RpcSessionRecord>;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ if (typeof obj.sessionId !== "string" || typeof obj.pid !== "number") return undefined;
76
+ return obj as RpcSessionRecord;
77
+ }
78
+
79
+ /**
80
+ * List live RPC sessions, reaping records whose process is gone or whose file is
81
+ * unparseable. Returns records sorted by `startedAt` ascending.
82
+ */
83
+ export async function listRpcSessions(agentDir?: string): Promise<RpcSessionRecord[]> {
84
+ const dir = rpcSessionsDir(agentDir);
85
+ let entries: string[];
86
+ try {
87
+ entries = await fs.readdir(dir);
88
+ } catch {
89
+ return [];
90
+ }
91
+ const live: RpcSessionRecord[] = [];
92
+ for (const entry of entries) {
93
+ if (!entry.endsWith(".json")) continue;
94
+ const file = path.join(dir, entry);
95
+ let raw: string;
96
+ try {
97
+ raw = await fs.readFile(file, "utf8");
98
+ } catch {
99
+ continue;
100
+ }
101
+ const record = parseRecord(raw);
102
+ if (!record || !isProcessAlive(record.pid)) {
103
+ await fs.rm(file, { force: true });
104
+ continue;
105
+ }
106
+ live.push(record);
107
+ }
108
+ return live.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
109
+ }
@@ -45,6 +45,30 @@ export function actionClassForScope(scope: BridgeCommandScope): RpcUnattendedAct
45
45
  }
46
46
  }
47
47
 
48
+ /** Runtime list of every v1 action class — membership-validation source for negotiate (#319). */
49
+ export const RPC_UNATTENDED_ACTION_CLASSES: readonly RpcUnattendedActionClass[] = [
50
+ "command.prompt",
51
+ "command.control",
52
+ "command.bash",
53
+ "command.export",
54
+ "command.session",
55
+ "command.model",
56
+ "command.message_read",
57
+ "command.host_tools",
58
+ "command.host_uri",
59
+ "command.admin",
60
+ "bash.readonly",
61
+ "bash.mutating",
62
+ "bash.destructive",
63
+ "git.force_push",
64
+ "file.delete",
65
+ "file.write",
66
+ "host_tool.invoke",
67
+ "host_uri.read",
68
+ "host_uri.write",
69
+ "auth.login",
70
+ ];
71
+
48
72
  const READONLY_COMMANDS = new Set([
49
73
  "ls",
50
74
  "cat",
@@ -24,7 +24,8 @@ import type {
24
24
  RpcUnattendedRefusalCode,
25
25
  } from "../../rpc/rpc-types";
26
26
  import type { BridgeCommandScope } from "./scopes";
27
- import { actionClassForScope, classifyBashAction } from "./unattended-action-policy";
27
+ import { BRIDGE_COMMAND_SCOPES, MANDATORY_FLOOR_COMMAND_SCOPES } from "./scopes";
28
+ import { actionClassForScope, classifyBashAction, RPC_UNATTENDED_ACTION_CLASSES } from "./unattended-action-policy";
28
29
 
29
30
  /** Coordinated abort surfaces invoked exactly once on a budget breach / abort. */
30
31
  export interface UnattendedAbortHooks {
@@ -157,8 +158,11 @@ export class UnattendedRunController {
157
158
  this.sessionId = ctx.sessionId;
158
159
  this.actor = declaration.actor;
159
160
  this.budget = budget;
160
- this.scopes = new Set(declaration.scopes);
161
- this.actionAllowlist = new Set(declaration.action_allowlist);
161
+ this.scopes = new Set([...declaration.scopes, ...MANDATORY_FLOOR_COMMAND_SCOPES]);
162
+ this.actionAllowlist = new Set([
163
+ ...declaration.action_allowlist,
164
+ ...MANDATORY_FLOOR_COMMAND_SCOPES.map(actionClassForScope),
165
+ ]);
162
166
  this.now = ctx.now ?? Date.now;
163
167
  this.audit = ctx.audit;
164
168
  this.abortHooks = ctx.abortHooks ?? {};
@@ -183,6 +187,22 @@ export class UnattendedRunController {
183
187
  "declaration.action_allowlist must be string[]",
184
188
  );
185
189
  }
190
+ const unknownScopes = d.scopes.filter(scope => !BRIDGE_COMMAND_SCOPES.includes(scope as BridgeCommandScope));
191
+ if (unknownScopes.length > 0) {
192
+ throw new UnattendedNegotiationError(
193
+ "invalid_unattended_declaration",
194
+ `declaration.scopes contains unknown scope(s): ${unknownScopes.join(", ")}`,
195
+ );
196
+ }
197
+ const unknownActions = d.action_allowlist.filter(
198
+ action => !RPC_UNATTENDED_ACTION_CLASSES.includes(action as RpcUnattendedActionClass),
199
+ );
200
+ if (unknownActions.length > 0) {
201
+ throw new UnattendedNegotiationError(
202
+ "invalid_unattended_declaration",
203
+ `declaration.action_allowlist contains unknown action class(es): ${unknownActions.join(", ")}`,
204
+ );
205
+ }
186
206
  const budget = validateBudget(d.budget);
187
207
  // Reject providers that cannot account for tokens/cost (fail-closed): require
188
208
  // an explicit positive capability signal — omitted/unknown is refused too.
@@ -31,6 +31,13 @@ import {
31
31
  } from "./unattended-run-controller";
32
32
  import { type GateStore, MemoryGateStore, type OpenGateInput, WorkflowGateBroker } from "./workflow-gate-broker";
33
33
 
34
+ /**
35
+ * RPC commands that perform agent/tool work and therefore consume one unit of the
36
+ * `max_tool_calls` budget. Read-only/control/cancellation commands are wall-time-bounded
37
+ * and scope-checked but must NOT charge the tool-call budget (issue 04).
38
+ */
39
+ const CHARGED_COMMAND_TYPES = new Set<RpcCommand["type"]>(["bash", "prompt", "steer", "follow_up", "abort_and_prompt"]);
40
+
34
41
  /** Minimal surface a skill runtime / ask tool uses to emit a gate and await its answer. */
35
42
  export interface WorkflowGateEmitter {
36
43
  /** True only when unattended mode has been negotiated. */
@@ -116,7 +123,15 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
116
123
 
117
124
  preflightCommand(command: RpcCommand): void {
118
125
  if (!this.#controller) return;
119
- this.#controller.preflightToolCall(`${command.type} preflight`);
126
+ const phase = `${command.type} preflight`;
127
+ // Always enforce wall-time; only charge the tool-call budget for commands that perform
128
+ // agent/tool work (issue 04). Read-only/control/cancellation commands must not consume
129
+ // max_tool_calls, but remain wall-time-bounded and scope/action-checked.
130
+ if (CHARGED_COMMAND_TYPES.has(command.type)) {
131
+ this.#controller.preflightToolCall(phase);
132
+ } else {
133
+ this.#controller.checkWallTime(phase);
134
+ }
120
135
  if (command.type === "bash") {
121
136
  this.#controller.authorizeBash(command.command);
122
137
  return;
package/src/sdk.ts CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  Snowflake,
33
33
  } from "@gajae-code/utils";
34
34
 
35
- import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
35
+ import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled, jobElapsedMs } from "./async";
36
36
  import { loadCapability } from "./capability";
37
37
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
38
38
  import { ModelRegistry } from "./config/model-registry";
@@ -50,6 +50,7 @@ import { CursorExecHandlers } from "./cursor";
50
50
  import "./discovery";
51
51
  import { resolveConfigValue } from "./config/resolve-config-value";
52
52
  import { getEmbeddedDefaultGjcSkills } from "./defaults/gjc-defaults";
53
+ import { BUNDLED_GROK_BUILD_EXTENSION_ID, getBundledGrokBuildExtensionFactory } from "./defaults/gjc-grok-cli";
53
54
  import { initializeWithSettings } from "./discovery";
54
55
  import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor";
55
56
  import { TtsrManager } from "./export/ttsr";
@@ -1124,7 +1125,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1124
1125
  const formattedResult = await formatAsyncResultForFollowUp(result);
1125
1126
  if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
1126
1127
 
1127
- const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
1128
+ const durationMs = job ? jobElapsedMs(job) : undefined;
1128
1129
  session.yieldQueue.enqueue<AsyncResultEntry>("async-result", {
1129
1130
  jobId,
1130
1131
  result: formattedResult,
@@ -1341,13 +1342,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1341
1342
  }
1342
1343
 
1343
1344
  // Extension/module discovery is quarantined; retain only the private
1344
- // runtime needed for explicitly supplied SDK extensions and custom tools.
1345
+ // runtime needed for bundled product extensions, explicitly supplied SDK
1346
+ // extension factories, and custom tools. Filesystem extension paths remain
1347
+ // ignored here even when options.additionalExtensionPaths is supplied.
1345
1348
  const extensionsResult: LoadExtensionsResult = options.preloadedExtensions ?? {
1346
1349
  extensions: [],
1347
1350
  errors: [],
1348
1351
  runtime: new ExtensionRuntime(),
1349
1352
  };
1350
1353
 
1354
+ if (!extensionsResult.extensions.some(extension => extension.path === BUNDLED_GROK_BUILD_EXTENSION_ID)) {
1355
+ const bundledGrokExtension = await loadExtensionFromFactory(
1356
+ getBundledGrokBuildExtensionFactory(),
1357
+ cwd,
1358
+ eventBus,
1359
+ extensionsResult.runtime,
1360
+ BUNDLED_GROK_BUILD_EXTENSION_ID,
1361
+ );
1362
+ extensionsResult.extensions.push(bundledGrokExtension);
1363
+ }
1364
+
1351
1365
  // Load inline extensions from factories
1352
1366
  if (inlineExtensions.length > 0) {
1353
1367
  for (let i = 0; i < inlineExtensions.length; i++) {
@@ -322,7 +322,10 @@ function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
322
322
 
323
323
  /** Listener function for agent session events */
324
324
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
325
- export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime" | "metadata">;
325
+ export type AsyncJobSnapshotItem = Pick<
326
+ AsyncJob,
327
+ "id" | "type" | "status" | "label" | "startTime" | "endTime" | "metadata"
328
+ >;
326
329
 
327
330
  export interface AsyncJobSnapshot {
328
331
  running: AsyncJobSnapshotItem[];
@@ -903,6 +906,7 @@ export class AgentSession {
903
906
  // Compaction state
904
907
  #compactionAbortController: AbortController | undefined = undefined;
905
908
  #autoCompactionAbortController: AbortController | undefined = undefined;
909
+ #prePromptContextCheckPromise: Promise<void> | undefined = undefined;
906
910
 
907
911
  // Branch summarization state
908
912
  #branchSummaryAbortController: AbortController | undefined = undefined;
@@ -1563,6 +1567,7 @@ export class AgentSession {
1563
1567
  status: job.status,
1564
1568
  label: job.label,
1565
1569
  startTime: job.startTime,
1570
+ endTime: job.endTime,
1566
1571
  metadata: job.metadata,
1567
1572
  }));
1568
1573
  const recent = manager.getRecentJobs(options?.recentLimit ?? 5, ownerFilter).map(job => ({
@@ -1571,6 +1576,7 @@ export class AgentSession {
1571
1576
  status: job.status,
1572
1577
  label: job.label,
1573
1578
  startTime: job.startTime,
1579
+ endTime: job.endTime,
1574
1580
  metadata: job.metadata,
1575
1581
  }));
1576
1582
  const delivery = manager.getDeliveryState(ownerFilter);
@@ -4754,7 +4760,11 @@ export class AgentSession {
4754
4760
  await this.#checkCompaction(lastAssistant, false);
4755
4761
  }
4756
4762
  if (!options?.skipCompactionCheck) {
4757
- await this.#checkEstimatedContextBeforePrompt();
4763
+ await this.#checkEstimatedContextBeforePrompt([
4764
+ ...(options?.prependMessages ?? []),
4765
+ message,
4766
+ ...this.#pendingNextTurnMessages,
4767
+ ]);
4758
4768
  }
4759
4769
 
4760
4770
  // Build messages array (session context, eager todo prelude, then active prompt message)
@@ -5219,7 +5229,9 @@ export class AgentSession {
5219
5229
  }
5220
5230
  await this.#syncSkillPromptActiveStateSafely(appMessage, true);
5221
5231
  try {
5222
- await this.agent.prompt(appMessage);
5232
+ await this.#promptWithMessage(appMessage, this.#getCustomMessageTextContent(appMessage), {
5233
+ skipPostPromptRecoveryWait: true,
5234
+ });
5223
5235
  } finally {
5224
5236
  await this.#syncSkillPromptActiveStateSafely(appMessage, false);
5225
5237
  }
@@ -5243,7 +5255,9 @@ export class AgentSession {
5243
5255
  }
5244
5256
  await this.#syncSkillPromptActiveStateSafely(appMessage, true);
5245
5257
  try {
5246
- await this.agent.prompt(appMessage);
5258
+ await this.#promptWithMessage(appMessage, this.#getCustomMessageTextContent(appMessage), {
5259
+ skipPostPromptRecoveryWait: true,
5260
+ });
5247
5261
  } finally {
5248
5262
  await this.#syncSkillPromptActiveStateSafely(appMessage, false);
5249
5263
  }
@@ -6546,7 +6560,23 @@ export class AgentSession {
6546
6560
  }
6547
6561
  }
6548
6562
 
6549
- async #checkEstimatedContextBeforePrompt(): Promise<void> {
6563
+ async #checkEstimatedContextBeforePrompt(pendingMessages: readonly AgentMessage[] = []): Promise<void> {
6564
+ if (this.#prePromptContextCheckPromise) {
6565
+ await this.#prePromptContextCheckPromise;
6566
+ }
6567
+
6568
+ const checkPromise = this.#checkEstimatedContextBeforePromptOnce(pendingMessages);
6569
+ this.#prePromptContextCheckPromise = checkPromise;
6570
+ try {
6571
+ await checkPromise;
6572
+ } finally {
6573
+ if (this.#prePromptContextCheckPromise === checkPromise) {
6574
+ this.#prePromptContextCheckPromise = undefined;
6575
+ }
6576
+ }
6577
+ }
6578
+
6579
+ async #checkEstimatedContextBeforePromptOnce(pendingMessages: readonly AgentMessage[]): Promise<void> {
6550
6580
  const model = this.model;
6551
6581
  if (!model) return;
6552
6582
  const contextWindow = model.contextWindow ?? 0;
@@ -6554,7 +6584,7 @@ export class AgentSession {
6554
6584
  const compactionSettings = this.settings.getGroup("compaction");
6555
6585
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
6556
6586
 
6557
- let contextTokens = this.#estimateContextTokens().tokens;
6587
+ let contextTokens = this.#estimateContextTokensForCompaction(pendingMessages).tokens;
6558
6588
  const maxOutputTokens = model.maxTokens ?? 0;
6559
6589
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) return;
6560
6590
 
@@ -9597,6 +9627,21 @@ export class AgentSession {
9597
9627
  */
9598
9628
  #estimateContextTokens(): {
9599
9629
  tokens: number;
9630
+ } {
9631
+ return this.#estimateContextTokensWith(message => this.#estimateMessageDisplayTokens(message));
9632
+ }
9633
+
9634
+ #estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
9635
+ tokens: number;
9636
+ } {
9637
+ const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageNativeContextTokens(message));
9638
+ return {
9639
+ tokens: estimate.tokens + this.#estimateMessagesNativeContextTokens(pendingMessages),
9640
+ };
9641
+ }
9642
+
9643
+ #estimateContextTokensWith(estimateMessage: (message: AgentMessage) => number): {
9644
+ tokens: number;
9600
9645
  } {
9601
9646
  const messages = this.messages;
9602
9647
 
@@ -9619,7 +9664,7 @@ export class AgentSession {
9619
9664
  // No usage data - estimate all messages
9620
9665
  let estimated = 0;
9621
9666
  for (const message of messages) {
9622
- estimated += estimateMessageTokensHeuristic(message);
9667
+ estimated += estimateMessage(message);
9623
9668
  }
9624
9669
  return {
9625
9670
  tokens: estimated,
@@ -9629,7 +9674,7 @@ export class AgentSession {
9629
9674
  const usageTokens = calculatePromptTokens(lastUsage);
9630
9675
  let trailingTokens = 0;
9631
9676
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
9632
- trailingTokens += estimateMessageTokensHeuristic(messages[i]);
9677
+ trailingTokens += estimateMessage(messages[i]);
9633
9678
  }
9634
9679
 
9635
9680
  return {
@@ -9637,6 +9682,30 @@ export class AgentSession {
9637
9682
  };
9638
9683
  }
9639
9684
 
9685
+ #estimateMessagesNativeContextTokens(messages: readonly AgentMessage[]): number {
9686
+ let tokens = 0;
9687
+ for (const message of messages) {
9688
+ tokens += this.#estimateMessageNativeContextTokens(message);
9689
+ }
9690
+ return tokens;
9691
+ }
9692
+
9693
+ #estimateMessageDisplayTokens(message: AgentMessage): number {
9694
+ let tokens = 0;
9695
+ for (const llmMessage of convertToLlm([message])) {
9696
+ tokens += estimateMessageTokensHeuristic(llmMessage);
9697
+ }
9698
+ return tokens;
9699
+ }
9700
+
9701
+ #estimateMessageNativeContextTokens(message: AgentMessage): number {
9702
+ let tokens = 0;
9703
+ for (const llmMessage of convertToLlm([message])) {
9704
+ tokens += estimateTokens(llmMessage);
9705
+ }
9706
+ return tokens;
9707
+ }
9708
+
9640
9709
  /**
9641
9710
  * Export session to HTML.
9642
9711
  * @param outputPath Optional output path (defaults to session directory)
@@ -267,7 +267,13 @@ export function externalizeImageDataSync(blobStore: BlobStore, base64Data: strin
267
267
  /**
268
268
  * Resolve an externalized provider image data URL back to its original string.
269
269
  * If the data is not a blob reference, returns it unchanged.
270
- * If the blob is missing, logs a warning and returns the reference as-is.
270
+ *
271
+ * LEGACY PERSISTED-IMAGE COMPATIBILITY BOUNDARY: when the persisted blob is missing
272
+ * (e.g. resuming an old session whose image blob was pruned), this warns and returns
273
+ * the reference as-is rather than throwing, so legacy resume degrades gracefully.
274
+ * New resident byte-sensitive TEXT uses the fail-closed path instead
275
+ * (`resolveTextBlobSync` -> `ResidentBlobMissingError`). Do NOT route new byte-sensitive
276
+ * resident data through this warn-and-return path.
271
277
  */
272
278
  export async function resolveImageDataUrl(blobStore: BlobStore, data: string): Promise<string> {
273
279
  const hash = parseBlobRef(data);
@@ -284,7 +290,11 @@ export async function resolveImageDataUrl(blobStore: BlobStore, data: string): P
284
290
  /**
285
291
  * Resolve a blob reference back to base64 data.
286
292
  * If the data is not a blob reference, returns it unchanged.
287
- * If the blob is missing, logs a warning and returns a placeholder.
293
+ *
294
+ * LEGACY PERSISTED-IMAGE COMPATIBILITY BOUNDARY: when the blob is missing this warns
295
+ * and returns the reference as-is (downstream sees an invalid base64 ref but does not
296
+ * crash), preserving legacy-session resume. Byte-sensitive resident TEXT is fail-closed
297
+ * via `resolveTextBlobSync`; do NOT route new byte-sensitive resident data here.
288
298
  */
289
299
  export async function resolveImageData(blobStore: BlobStore, data: string): Promise<string> {
290
300
  const hash = parseBlobRef(data);
@@ -322,7 +332,14 @@ export function resolveImageDataSync(blobStore: BlobStore, data: string): string
322
332
  return buffer.toString("base64");
323
333
  }
324
334
 
325
- /** Synchronously resolve a blob reference back to utf8 text. */
335
+ /**
336
+ * Synchronously resolve a blob reference back to utf8 text.
337
+ *
338
+ * FAIL-CLOSED byte-sensitive path: a missing resident blob throws
339
+ * `ResidentBlobMissingError` rather than degrading, so a missing resident text blob can
340
+ * never silently leak a `blob:sha256:` ref into provider payloads, UI, or exports.
341
+ * (Contrast the legacy persisted-image warn-and-return resolvers above.)
342
+ */
326
343
  export function resolveTextBlobSync(
327
344
  blobStore: BlobStore,
328
345
  data: string,
@@ -336,3 +353,42 @@ export function resolveTextBlobSync(
336
353
  }
337
354
  return buffer.toString("utf8");
338
355
  }
356
+
357
+ /**
358
+ * FAIL-CLOSED resident variant of {@link resolveImageDataUrlSync}: a missing resident
359
+ * image-data-url blob throws `ResidentBlobMissingError` ("imageUrl") instead of warn-returning,
360
+ * so resident byte-sensitive provider image data can never leak a `blob:sha256:` ref into
361
+ * materialized entries, context, or provider payloads. The warn-and-return `resolveImageDataUrl*`
362
+ * resolvers remain ONLY for legacy persisted-image resume.
363
+ */
364
+ export function resolveResidentImageDataUrlSync(
365
+ blobStore: BlobStore,
366
+ data: string,
367
+ context?: { sessionId?: string; sessionFile?: string },
368
+ ): string {
369
+ const hash = parseBlobRef(data);
370
+ if (!hash) return data;
371
+ const buffer = blobStore.getSync(hash);
372
+ if (!buffer) {
373
+ throw new ResidentBlobMissingError(hash, "imageUrl", context?.sessionId, context?.sessionFile);
374
+ }
375
+ return buffer.toString("utf8");
376
+ }
377
+
378
+ /**
379
+ * FAIL-CLOSED resident variant of {@link resolveImageDataSync}: a missing resident image blob
380
+ * throws `ResidentBlobMissingError` ("imageData") instead of warn-returning a placeholder.
381
+ */
382
+ export function resolveResidentImageDataSync(
383
+ blobStore: BlobStore,
384
+ data: string,
385
+ context?: { sessionId?: string; sessionFile?: string },
386
+ ): string {
387
+ const hash = parseBlobRef(data);
388
+ if (!hash) return data;
389
+ const buffer = blobStore.getSync(hash);
390
+ if (!buffer) {
391
+ throw new ResidentBlobMissingError(hash, "imageData", context?.sessionId, context?.sessionFile);
392
+ }
393
+ return buffer.toString("base64");
394
+ }
@@ -41,9 +41,9 @@ import {
41
41
  isImageDataUrl,
42
42
  MemoryBlobStore,
43
43
  resolveImageData,
44
- resolveImageDataSync,
45
44
  resolveImageDataUrl,
46
- resolveImageDataUrlSync,
45
+ resolveResidentImageDataSync,
46
+ resolveResidentImageDataUrlSync,
47
47
  resolveTextBlobSync,
48
48
  } from "./blob-store";
49
49
  import {
@@ -1265,9 +1265,9 @@ function materializeResidentValueSync(
1265
1265
  if (cached !== undefined) return cached;
1266
1266
  const resolved =
1267
1267
  obj.kind === "imageUrl"
1268
- ? resolveImageDataUrlSync(stores.imageStore, obj.ref)
1268
+ ? resolveResidentImageDataUrlSync(stores.imageStore, obj.ref, stores)
1269
1269
  : obj.kind === "imageData"
1270
- ? resolveImageDataSync(stores.imageStore, obj.ref)
1270
+ ? resolveResidentImageDataSync(stores.imageStore, obj.ref, stores)
1271
1271
  : resolveTextBlobSync(stores.textStore, obj.ref, stores);
1272
1272
  cache.set(cacheKey, resolved);
1273
1273
  return resolved;
@@ -9,14 +9,20 @@ These instructions teach a Hermes-style coordinator how to operate GJC through t
9
9
  1. Use `{{TOOL_PREFIX}}_list_sessions` to find an existing session, or `{{TOOL_PREFIX}}_start_session` when a new session is required and mutation is enabled.
10
10
  2. Send exactly one bounded task prompt with `{{TOOL_PREFIX}}_send_prompt`.
11
11
  3. Store the returned `turn_id`.
12
- 4. Poll `{{TOOL_PREFIX}}_read_turn` or `{{TOOL_PREFIX}}_await_turn` for that `turn_id` until the turn is terminal.
12
+ 4. Prefer `{{TOOL_PREFIX}}_watch_events` with the stored `latest_seq` for event-driven progress; fall back to `{{TOOL_PREFIX}}_read_turn` or `{{TOOL_PREFIX}}_await_turn` for a specific `turn_id` until terminal.
13
13
  If a second task is needed while one turn is active, pass `queue: true`; the next queued turn is promoted after the active turn is reported terminal.
14
14
  5. If GJC asks a structured question, use `{{TOOL_PREFIX}}_list_questions` and answer with `{{TOOL_PREFIX}}_submit_question_answer`.
15
15
  6. Use `{{TOOL_PREFIX}}_report_status` for coordinator-visible status and final reports.
16
16
  7. Use `{{TOOL_PREFIX}}_read_tail` only as advisory debug output when structured turn state is insufficient.
17
17
 
18
+ ## Event watch
19
+
20
+ `{{TOOL_PREFIX}}_watch_events` is a bounded long-poll read tool. Call it with `after_seq` set to the last stored sequence number, optional `session_id` or `event_types`, `timeout_ms` up to 30000, and `limit` up to 100. Store the returned `latest_seq` before the next wait. A timeout with no events is not failure; call again or use the turn/status read tools for a snapshot.
21
+
18
22
  Do not report completion to the user until the GJC turn is terminal. Do not infer completion from terminal scrollback alone.
19
23
 
24
+ Coordinator MCP is a durable polling/await bridge, not a push subscription stream. Use `{{TOOL_PREFIX}}_read_coordination_status`, `{{TOOL_PREFIX}}_read_turn`, and bounded `{{TOOL_PREFIX}}_await_turn` as the authoritative consumption surface.
25
+
20
26
  ## Worktree, model, and provider policy
21
27
 
22
28
  The Hermes bridge does not choose a model/provider. Generated setup configures `GJC_COORDINATOR_MCP_SESSION_COMMAND` to `gjc --worktree` by default, so GJC creates and tracks the worktree while still using normal local model/provider resolution. Keep worktree creation inside GJC rather than creating unmanaged Hermes-side git worktrees; this preserves the original project identity for session listing and resume. If the operator config supplies a different `GJC_COORDINATOR_MCP_SESSION_COMMAND`, preserve it as explicit user intent.