@gajae-code/coding-agent 0.4.5 → 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 (185) hide show
  1. package/CHANGELOG.md +62 -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/commands/harness.d.ts +3 -0
  7. package/dist/types/config/file-lock-gc.d.ts +5 -0
  8. package/dist/types/config/file-lock.d.ts +7 -0
  9. package/dist/types/config/model-profile-activation.d.ts +11 -2
  10. package/dist/types/config/model-profiles.d.ts +7 -0
  11. package/dist/types/config/model-registry.d.ts +3 -0
  12. package/dist/types/config/model-resolver.d.ts +2 -0
  13. package/dist/types/config/models-config-schema.d.ts +30 -0
  14. package/dist/types/config/settings-schema.d.ts +4 -3
  15. package/dist/types/coordinator/contract.d.ts +1 -1
  16. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  24. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  25. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  26. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  27. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  28. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  29. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  30. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  31. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  32. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  33. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  34. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  35. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
  38. package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
  39. package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
  40. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  41. package/dist/types/harness-control-plane/owner.d.ts +8 -1
  42. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  43. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  44. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  45. package/dist/types/harness-control-plane/types.d.ts +4 -0
  46. package/dist/types/hindsight/mental-models.d.ts +5 -5
  47. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  48. package/dist/types/modes/components/model-selector.d.ts +1 -12
  49. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  50. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  51. package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
  52. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  53. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  54. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  56. package/dist/types/sdk.d.ts +5 -0
  57. package/dist/types/session/agent-session.d.ts +3 -1
  58. package/dist/types/session/blob-store.d.ts +59 -4
  59. package/dist/types/session/session-manager.d.ts +24 -6
  60. package/dist/types/session/streaming-output.d.ts +3 -2
  61. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  62. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  63. package/dist/types/task/receipt.d.ts +1 -0
  64. package/dist/types/task/types.d.ts +7 -0
  65. package/dist/types/thinking-metadata.d.ts +16 -0
  66. package/dist/types/thinking.d.ts +3 -12
  67. package/dist/types/tools/ask.d.ts +15 -1
  68. package/dist/types/tools/index.d.ts +2 -0
  69. package/dist/types/tools/resolve.d.ts +0 -10
  70. package/dist/types/tools/subagent.d.ts +6 -0
  71. package/dist/types/utils/tool-choice.d.ts +14 -1
  72. package/package.json +7 -7
  73. package/src/async/job-manager.ts +52 -0
  74. package/src/cli/args.ts +3 -0
  75. package/src/cli/auth-broker-cli.ts +1 -0
  76. package/src/cli/list-models.ts +13 -1
  77. package/src/cli.ts +9 -4
  78. package/src/commands/gc.ts +22 -0
  79. package/src/commands/harness.ts +43 -5
  80. package/src/commands/launch.ts +2 -2
  81. package/src/commands/session.ts +3 -1
  82. package/src/config/file-lock-gc.ts +181 -0
  83. package/src/config/file-lock.ts +14 -0
  84. package/src/config/model-profile-activation.ts +15 -3
  85. package/src/config/model-profiles.ts +264 -56
  86. package/src/config/model-resolver.ts +9 -6
  87. package/src/config/models-config-schema.ts +1 -0
  88. package/src/config/settings-schema.ts +6 -3
  89. package/src/coordinator/contract.ts +1 -0
  90. package/src/coordinator-mcp/server.ts +513 -26
  91. package/src/cursor.ts +16 -2
  92. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  93. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  94. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  95. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  96. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  97. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  98. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  99. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  100. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  101. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  102. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  103. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  104. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  105. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  106. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  107. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  108. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  109. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  110. package/src/defaults/gjc-defaults.ts +7 -0
  111. package/src/defaults/gjc-grok-cli.ts +22 -0
  112. package/src/export/html/index.ts +13 -9
  113. package/src/extensibility/extensions/index.ts +1 -0
  114. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  115. package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
  116. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  117. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  118. package/src/gjc-runtime/gc-render.ts +70 -0
  119. package/src/gjc-runtime/gc-runtime.ts +403 -0
  120. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  121. package/src/gjc-runtime/ralplan-runtime.ts +58 -7
  122. package/src/gjc-runtime/state-renderer.ts +12 -3
  123. package/src/gjc-runtime/state-runtime.ts +46 -29
  124. package/src/gjc-runtime/team-gc.ts +49 -0
  125. package/src/gjc-runtime/team-runtime.ts +211 -8
  126. package/src/gjc-runtime/tmux-common.ts +29 -0
  127. package/src/gjc-runtime/tmux-gc.ts +176 -0
  128. package/src/gjc-runtime/tmux-sessions.ts +68 -12
  129. package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
  130. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  131. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  132. package/src/harness-control-plane/gc-adapter.ts +184 -0
  133. package/src/harness-control-plane/owner.ts +89 -27
  134. package/src/harness-control-plane/receipt-spool.ts +128 -0
  135. package/src/harness-control-plane/state-machine.ts +27 -6
  136. package/src/harness-control-plane/storage.ts +93 -0
  137. package/src/harness-control-plane/types.ts +4 -0
  138. package/src/hindsight/mental-models.ts +17 -16
  139. package/src/internal-urls/docs-index.generated.ts +14 -8
  140. package/src/main.ts +7 -2
  141. package/src/modes/components/assistant-message.ts +26 -14
  142. package/src/modes/components/diff.ts +97 -0
  143. package/src/modes/components/hook-selector.ts +19 -0
  144. package/src/modes/components/model-selector.ts +370 -181
  145. package/src/modes/components/status-line/segments.ts +1 -1
  146. package/src/modes/components/tool-execution.ts +30 -13
  147. package/src/modes/controllers/command-controller.ts +25 -6
  148. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  149. package/src/modes/controllers/selector-controller.ts +34 -42
  150. package/src/modes/rpc/rpc-client.ts +3 -2
  151. package/src/modes/rpc/rpc-mode.ts +187 -39
  152. package/src/modes/rpc/rpc-types.ts +5 -2
  153. package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
  154. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  155. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  156. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  157. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  158. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  159. package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
  160. package/src/sdk.ts +46 -5
  161. package/src/secrets/obfuscator.ts +102 -27
  162. package/src/session/agent-session.ts +179 -25
  163. package/src/session/blob-store.ts +148 -6
  164. package/src/session/session-manager.ts +311 -60
  165. package/src/session/streaming-output.ts +185 -122
  166. package/src/session/tool-choice-queue.ts +23 -0
  167. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  168. package/src/skill-state/workflow-hud.ts +106 -10
  169. package/src/slash-commands/builtin-registry.ts +3 -2
  170. package/src/task/executor.ts +78 -6
  171. package/src/task/receipt.ts +5 -0
  172. package/src/task/render.ts +21 -1
  173. package/src/task/types.ts +8 -0
  174. package/src/thinking-metadata.ts +51 -0
  175. package/src/thinking.ts +26 -46
  176. package/src/tools/ask.ts +56 -1
  177. package/src/tools/bash.ts +1 -1
  178. package/src/tools/index.ts +2 -0
  179. package/src/tools/job.ts +3 -2
  180. package/src/tools/monitor.ts +36 -1
  181. package/src/tools/resolve.ts +93 -18
  182. package/src/tools/subagent-render.ts +9 -0
  183. package/src/tools/subagent.ts +26 -2
  184. package/src/utils/edit-mode.ts +1 -1
  185. package/src/utils/tool-choice.ts +45 -16
@@ -17,12 +17,26 @@ import type { OpenGateInput } from "./workflow-gate-broker";
17
17
  /** "Other (type your own)" sentinel, mirroring the interactive ask tool. */
18
18
  export const GATE_OTHER_OPTION = "Other (type your own)";
19
19
 
20
+ /** Optional structured deep-interview round metadata supplied by the agent. */
21
+ export interface AskGateDeepInterviewState {
22
+ round_id?: string;
23
+ round: number;
24
+ component: string;
25
+ dimension: string;
26
+ ambiguity: number;
27
+ }
28
+
20
29
  export interface AskGateQuestion {
21
30
  id: string;
22
31
  question: string;
23
32
  options: Array<{ label: string }>;
24
33
  multi?: boolean;
25
34
  recommended?: number;
35
+ /**
36
+ * Structured round metadata. When present it is the authoritative source for gate
37
+ * `stage_state`; when absent, the question text is regex-parsed as a fallback.
38
+ */
39
+ deepInterview?: AskGateDeepInterviewState;
26
40
  }
27
41
 
28
42
  export interface AskGateResult {
@@ -130,6 +144,19 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
130
144
  };
131
145
  }
132
146
 
147
+ /** Build `stage_state` round metadata from the structured param (authoritative when present). */
148
+ function structuredDeepInterviewState(meta: AskGateDeepInterviewState): Record<string, unknown> {
149
+ const state: Record<string, unknown> = {
150
+ deep_interview_metadata: true,
151
+ round: meta.round,
152
+ component: meta.component,
153
+ dimension: meta.dimension,
154
+ ambiguity: meta.ambiguity,
155
+ };
156
+ if (meta.round_id !== undefined) state.round_id = meta.round_id;
157
+ return state;
158
+ }
159
+
133
160
  /** Build the `workflow_gate` open-input for one deep-interview question. */
134
161
  export function questionToGate(question: AskGateQuestion): OpenGateInput {
135
162
  const labels = question.options.map(o => o.label);
@@ -151,7 +178,9 @@ export function questionToGate(question: AskGateQuestion): OpenGateInput {
151
178
  multi: question.multi ?? false,
152
179
  options: labels,
153
180
  other_option: GATE_OTHER_OPTION,
154
- ...deepInterviewQuestionState(question.question),
181
+ ...(question.deepInterview
182
+ ? structuredDeepInterviewState(question.deepInterview)
183
+ : deepInterviewQuestionState(question.question)),
155
184
  },
156
185
  },
157
186
  };
@@ -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";
@@ -133,7 +134,7 @@ import { ToolContextStore } from "./tools/context";
133
134
  import { getImageGenTools } from "./tools/image-gen";
134
135
  import { wrapToolWithMetaNotice } from "./tools/output-meta";
135
136
  import { EventBus } from "./utils/event-bus";
136
- import { buildNamedToolChoice } from "./utils/tool-choice";
137
+ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "./utils/tool-choice";
137
138
  import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
138
139
 
139
140
  type AsyncResultEntry = {
@@ -234,6 +235,8 @@ export interface CreateAgentSessionOptions {
234
235
  modelPattern?: string;
235
236
  /** Thinking selector. Default: from settings, else unset */
236
237
  thinkingLevel?: ThinkingLevel;
238
+ /** Runtime substitution metadata for the initial model_change session event. */
239
+ modelSubstitution?: { requestedModel: Model; reason: string };
237
240
  /** Models available for cycling (Ctrl+P in interactive mode) */
238
241
  scopedModels?: ScopedModelSelection[];
239
242
 
@@ -1122,7 +1125,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1122
1125
  const formattedResult = await formatAsyncResultForFollowUp(result);
1123
1126
  if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
1124
1127
 
1125
- const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
1128
+ const durationMs = job ? jobElapsedMs(job) : undefined;
1126
1129
  session.yieldQueue.enqueue<AsyncResultEntry>("async-result", {
1127
1130
  jobId,
1128
1131
  result: formattedResult,
@@ -1212,6 +1215,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1212
1215
  const m = session.model;
1213
1216
  return m ? buildNamedToolChoice(name, m) : undefined;
1214
1217
  },
1218
+ buildToolChoiceResult: name => buildNamedToolChoiceResult(name, session.model),
1215
1219
  steer: msg =>
1216
1220
  session.agent.steer({
1217
1221
  role: "custom",
@@ -1338,13 +1342,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1338
1342
  }
1339
1343
 
1340
1344
  // Extension/module discovery is quarantined; retain only the private
1341
- // 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.
1342
1348
  const extensionsResult: LoadExtensionsResult = options.preloadedExtensions ?? {
1343
1349
  extensions: [],
1344
1350
  errors: [],
1345
1351
  runtime: new ExtensionRuntime(),
1346
1352
  };
1347
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
+
1348
1365
  // Load inline extensions from factories
1349
1366
  if (inlineExtensions.length > 0) {
1350
1367
  for (let i = 0; i < inlineExtensions.length; i++) {
@@ -1898,6 +1915,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1898
1915
  }
1899
1916
  return result;
1900
1917
  },
1918
+ onToolChoiceIncapability: event => {
1919
+ const droppedLabel = session?.toolChoiceQueue.degradeInFlight(event.reason);
1920
+ logger.debug("Dropped in-flight tool choice after runtime incapability", {
1921
+ droppedLabel,
1922
+ api: event.api,
1923
+ provider: event.provider,
1924
+ model: event.model,
1925
+ requestedLevel: event.requestedLevel,
1926
+ resolvedLevel: event.resolvedLevel,
1927
+ reason: event.reason,
1928
+ registryKey: event.registryKey,
1929
+ });
1930
+ },
1901
1931
  intentTracing: !!intentField,
1902
1932
  getToolChoice: () => session?.nextToolChoice(),
1903
1933
  telemetry: options.telemetry,
@@ -1912,7 +1942,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1912
1942
  } else {
1913
1943
  // Save initial model, thinking level, and service tier for new sessions so they can be restored on resume.
1914
1944
  if (model) {
1915
- sessionManager.appendModelChange(`${model.provider}/${model.id}`);
1945
+ const substitution = options.modelSubstitution;
1946
+ sessionManager.appendModelChange(
1947
+ `${model.provider}/${model.id}`,
1948
+ undefined,
1949
+ substitution
1950
+ ? {
1951
+ previousModel: `${substitution.requestedModel.provider}/${substitution.requestedModel.id}`,
1952
+ reason: substitution.reason,
1953
+ thinkingLevel: thinkingLevel ?? null,
1954
+ }
1955
+ : undefined,
1956
+ );
1916
1957
  }
1917
1958
  sessionManager.appendThinkingLevelChange(thinkingLevel);
1918
1959
  if (initialServiceTier) {
@@ -73,9 +73,24 @@ export class SecretObfuscator {
73
73
  /** Replace-mode plain mappings: secret → replacement */
74
74
  #replaceMappings = new Map<string, string>();
75
75
 
76
+ /** Replace-mode plain mappings sorted longest-first for deterministic longest-match replacement. */
77
+ #sortedReplaceMappings: Array<{ secret: string; replacement: string }> = [];
78
+
79
+ /** Obfuscate-mode plain and regex-discovered mappings sorted longest-first. */
80
+ #sortedObfuscateMappings: Array<{ secret: string; index: number; placeholder: string }> = [];
81
+
82
+ /** Reverse lookup for obfuscate-mode secrets to avoid scanning mappings. */
83
+ #obfuscateIndexBySecret = new Map<string, number>();
84
+
76
85
  /** Reverse lookup for deobfuscation: placeholder → secret */
77
86
  #deobfuscateMap = new Map<string, string>();
78
87
 
88
+ /** Combined plain-secret regex cache for single-pass replacement. */
89
+ #combinedPlainRegex: RegExp | undefined;
90
+ #combinedPlainReplacementBySecret = new Map<string, string>();
91
+ #combinedPlainRegexDirty = true;
92
+ #useSequentialPlainReplacement = false;
93
+
79
94
  /** Next available index for regex match discoveries */
80
95
  #nextIndex: number;
81
96
 
@@ -93,6 +108,7 @@ export class SecretObfuscator {
93
108
  this.#plainMappings.set(entry.content, index);
94
109
  this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
95
110
  this.#deobfuscateMap.set(placeholder, entry.content);
111
+ this.#obfuscateIndexBySecret.set(entry.content, index);
96
112
  index++;
97
113
  } else {
98
114
  // replace mode
@@ -111,6 +127,16 @@ export class SecretObfuscator {
111
127
  }
112
128
 
113
129
  this.#nextIndex = index;
130
+ this.#sortedReplaceMappings = [...this.#replaceMappings]
131
+ .sort((a, b) => b[0].length - a[0].length)
132
+ .map(([secret, replacement]) => ({ secret, replacement }));
133
+ this.#sortedObfuscateMappings = [...this.#plainMappings]
134
+ .sort((a, b) => b[0].length - a[0].length)
135
+ .map(([secret, mappingIndex]) => ({
136
+ secret,
137
+ index: mappingIndex,
138
+ placeholder: this.#obfuscateMappings.get(mappingIndex)!.placeholder,
139
+ }));
114
140
  this.#hasAny = entries.length > 0;
115
141
  }
116
142
 
@@ -121,18 +147,7 @@ export class SecretObfuscator {
121
147
  /** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */
122
148
  obfuscate(text: string): string {
123
149
  if (!this.#hasAny) return text;
124
- let result = text;
125
-
126
- // 1. Process replace-mode plain secrets
127
- for (const [secret, replacement] of [...this.#replaceMappings].sort((a, b) => b[0].length - a[0].length)) {
128
- result = replaceAll(result, secret, replacement);
129
- }
130
-
131
- // 2. Process obfuscate-mode plain secrets
132
- for (const [secret, index] of [...this.#plainMappings].sort((a, b) => b[0].length - a[0].length)) {
133
- const mapping = this.#obfuscateMappings.get(index)!;
134
- result = replaceAll(result, secret, mapping.placeholder);
135
- }
150
+ let result = this.#obfuscatePlainMappings(text);
136
151
 
137
152
  // 3. Process regex entries — discover new matches
138
153
  for (const entry of this.#regexEntries) {
@@ -160,6 +175,9 @@ export class SecretObfuscator {
160
175
  const placeholder = buildPlaceholder(index);
161
176
  this.#obfuscateMappings.set(index, { secret: matchValue, placeholder });
162
177
  this.#deobfuscateMap.set(placeholder, matchValue);
178
+ this.#obfuscateIndexBySecret.set(matchValue, index);
179
+ this.#insertSortedObfuscateMapping({ secret: matchValue, index, placeholder });
180
+ this.#combinedPlainRegexDirty = true;
163
181
  }
164
182
  const mapping = this.#obfuscateMappings.get(index)!;
165
183
  result = replaceAll(result, matchValue, mapping.placeholder);
@@ -186,15 +204,74 @@ export class SecretObfuscator {
186
204
 
187
205
  /** Find the obfuscate index for a known secret value. */
188
206
  #findObfuscateIndex(secret: string): number | undefined {
189
- // Check plain mappings first
190
- const plainIndex = this.#plainMappings.get(secret);
191
- if (plainIndex !== undefined) return plainIndex;
207
+ return this.#obfuscateIndexBySecret.get(secret);
208
+ }
209
+
210
+ #insertSortedObfuscateMapping(mapping: { secret: string; index: number; placeholder: string }): void {
211
+ let lo = 0;
212
+ let hi = this.#sortedObfuscateMappings.length;
213
+ while (lo < hi) {
214
+ const mid = (lo + hi) >> 1;
215
+ if (this.#sortedObfuscateMappings[mid]!.secret.length < mapping.secret.length) {
216
+ hi = mid;
217
+ } else {
218
+ lo = mid + 1;
219
+ }
220
+ }
221
+ this.#sortedObfuscateMappings.splice(lo, 0, mapping);
222
+ }
223
+
224
+ #obfuscatePlainMappings(text: string): string {
225
+ this.#ensureCombinedPlainRegex();
226
+ if (this.#useSequentialPlainReplacement) return this.#obfuscatePlainMappingsSequential(text);
227
+ if (!this.#combinedPlainRegex) return text;
228
+ return text.replace(
229
+ this.#combinedPlainRegex,
230
+ match => this.#combinedPlainReplacementBySecret.get(match) ?? match,
231
+ );
232
+ }
192
233
 
193
- // Check regex-discovered mappings
194
- for (const [index, mapping] of this.#obfuscateMappings) {
195
- if (mapping.secret === secret) return index;
234
+ #obfuscatePlainMappingsSequential(text: string): string {
235
+ let result = text;
236
+ for (const mapping of this.#sortedReplaceMappings) {
237
+ result = replaceAll(result, mapping.secret, mapping.replacement);
238
+ }
239
+ for (const mapping of this.#sortedObfuscateMappings) {
240
+ result = replaceAll(result, mapping.secret, mapping.placeholder);
196
241
  }
197
- return undefined;
242
+ return result;
243
+ }
244
+
245
+ #ensureCombinedPlainRegex(): void {
246
+ if (!this.#combinedPlainRegexDirty) return;
247
+ this.#combinedPlainRegexDirty = false;
248
+ this.#combinedPlainReplacementBySecret = new Map<string, string>();
249
+
250
+ const mappings = [
251
+ ...this.#sortedReplaceMappings.map(mapping => ({ secret: mapping.secret, replacement: mapping.replacement })),
252
+ ...this.#sortedObfuscateMappings.map(mapping => ({
253
+ secret: mapping.secret,
254
+ replacement: mapping.placeholder,
255
+ })),
256
+ ];
257
+
258
+ this.#useSequentialPlainReplacement = mappings.some((mapping, index) =>
259
+ mappings.some(
260
+ (other, otherIndex) =>
261
+ other.secret.length > 0 &&
262
+ (mapping.replacement.includes(other.secret) ||
263
+ (index !== otherIndex &&
264
+ (mapping.secret.includes(other.secret) || other.secret.includes(mapping.secret)))),
265
+ ),
266
+ );
267
+ for (const mapping of mappings) {
268
+ if (!this.#combinedPlainReplacementBySecret.has(mapping.secret))
269
+ this.#combinedPlainReplacementBySecret.set(mapping.secret, mapping.replacement);
270
+ }
271
+ this.#combinedPlainRegex =
272
+ mappings.length > 0
273
+ ? new RegExp(mappings.map(mapping => escapeRegex(mapping.secret)).join("|"), "g")
274
+ : undefined;
198
275
  }
199
276
  }
200
277
 
@@ -238,14 +315,12 @@ export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Messag
238
315
 
239
316
  /** Replace all occurrences of `search` in `text` with `replacement`. */
240
317
  function replaceAll(text: string, search: string, replacement: string): string {
241
- if (search.length === 0) return text;
242
- let result = text;
243
- let idx = result.indexOf(search);
244
- while (idx !== -1) {
245
- result = result.slice(0, idx) + replacement + result.slice(idx + search.length);
246
- idx = result.indexOf(search, idx + replacement.length);
247
- }
248
- return result;
318
+ if (search.length === 0 || !text.includes(search)) return text;
319
+ return text.split(search).join(replacement);
320
+ }
321
+
322
+ function escapeRegex(value: string): string {
323
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
249
324
  }
250
325
 
251
326
  /** Deep-walk an object, transforming all string values. */