@gajae-code/coding-agent 0.11.3 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +20 -0
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  9. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  10. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  11. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  12. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  13. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  14. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  15. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  16. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  18. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  19. package/dist/types/main.d.ts +2 -2
  20. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  21. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  22. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  23. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/components/tool-transcript-format.d.ts +77 -0
  26. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  27. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  28. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  29. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  30. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  31. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  32. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  33. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  34. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  35. package/dist/types/sdk/broker/broker.d.ts +1 -0
  36. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  37. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  38. package/dist/types/sdk/broker/internal.d.ts +3 -0
  39. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  40. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  41. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
  43. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  44. package/dist/types/sdk/bus/config.d.ts +22 -0
  45. package/dist/types/sdk/bus/index.d.ts +162 -2
  46. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  47. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  48. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  52. package/dist/types/sdk/session.d.ts +16 -1
  53. package/dist/types/secrets/index.d.ts +4 -2
  54. package/dist/types/secrets/obfuscator.d.ts +1 -1
  55. package/dist/types/session/agent-session.d.ts +31 -2
  56. package/dist/types/session/artifacts.d.ts +30 -8
  57. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  58. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  59. package/dist/types/session/session-manager.d.ts +88 -18
  60. package/dist/types/session/session-storage.d.ts +27 -0
  61. package/dist/types/session/streaming-output.d.ts +4 -0
  62. package/dist/types/task/executor.d.ts +9 -0
  63. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  64. package/dist/types/tools/ask.d.ts +87 -12
  65. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  66. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  67. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  68. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  69. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  70. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/path-utils.d.ts +9 -14
  73. package/dist/types/tools/render-utils.d.ts +1 -8
  74. package/dist/types/tools/resource-gc.d.ts +14 -0
  75. package/dist/types/utils/image-loading.d.ts +23 -0
  76. package/dist/types/utils/image-resize.d.ts +2 -0
  77. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  78. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  79. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  80. package/dist/types/web/scrapers/types.d.ts +1 -5
  81. package/package.json +25 -9
  82. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  83. package/scripts/run-threat-model-check.ts +60 -0
  84. package/src/cli/notify-cli.ts +40 -18
  85. package/src/cli/worktree-cli.ts +9 -287
  86. package/src/cli.ts +10 -9
  87. package/src/commands/gc.ts +11 -1
  88. package/src/commands/sdk.ts +10 -4
  89. package/src/commands/worktree.ts +10 -53
  90. package/src/config/model-profiles.ts +15 -15
  91. package/src/config/settings-schema.ts +17 -0
  92. package/src/daemon/runtime.ts +2 -2
  93. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  94. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  95. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  96. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  97. package/src/eval/js/shared/runtime.ts +1 -1
  98. package/src/eval/py/index.ts +2 -2
  99. package/src/export/html/template.generated.ts +1 -1
  100. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  101. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  102. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  103. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  104. package/src/gjc-runtime/gc-render.ts +19 -1
  105. package/src/gjc-runtime/gc-runtime.ts +68 -3
  106. package/src/gjc-runtime/launch-tmux.ts +61 -6
  107. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  108. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  109. package/src/gjc-runtime/state-argv.ts +292 -0
  110. package/src/gjc-runtime/state-runtime.ts +80 -222
  111. package/src/gjc-runtime/team-runtime.ts +1 -1
  112. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  113. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  114. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  115. package/src/harness-control-plane/owner.ts +59 -3
  116. package/src/hindsight/client.ts +65 -2
  117. package/src/internal-urls/agent-protocol.ts +76 -5
  118. package/src/internal-urls/docs-index.generated.ts +7 -6
  119. package/src/internal-urls/local-protocol.ts +416 -18
  120. package/src/lsp/index.ts +4 -88
  121. package/src/main.ts +37 -13
  122. package/src/modes/acp/acp-agent.ts +8 -0
  123. package/src/modes/components/ansi-display-validator.ts +54 -0
  124. package/src/modes/components/custom-editor.ts +103 -32
  125. package/src/modes/components/custom-provider-wizard.ts +26 -1
  126. package/src/modes/components/notifications-settings-editor.ts +26 -2
  127. package/src/modes/components/session-observer-overlay.ts +40 -37
  128. package/src/modes/components/settings-selector.ts +3 -1
  129. package/src/modes/components/tool-transcript-format.ts +393 -0
  130. package/src/modes/components/transcript-viewer-overlay.ts +63 -1
  131. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  132. package/src/modes/controllers/input-controller.ts +98 -37
  133. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  134. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  135. package/src/modes/controllers/selector-controller.ts +71 -9
  136. package/src/modes/interactive-mode.ts +14 -2
  137. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  138. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  139. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  140. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  141. package/src/plan-mode/approved-plan.ts +3 -1
  142. package/src/prompts/agents/architect.md +1 -0
  143. package/src/prompts/agents/critic.md +1 -0
  144. package/src/prompts/system/custom-system-prompt.md +1 -1
  145. package/src/prompts/system/system-prompt.md +2 -1
  146. package/src/prompts/tools/lsp.md +3 -3
  147. package/src/runtime-mcp/manager.ts +16 -1
  148. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  149. package/src/sdk/broker/broker.ts +139 -32
  150. package/src/sdk/broker/discovery.ts +125 -0
  151. package/src/sdk/broker/ensure.ts +103 -19
  152. package/src/sdk/broker/internal.ts +10 -0
  153. package/src/sdk/broker/session-index.ts +309 -38
  154. package/src/sdk/broker/state-version.ts +5 -2
  155. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  156. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  157. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  158. package/src/sdk/bus/config-commands.ts +22 -0
  159. package/src/sdk/bus/config.ts +37 -0
  160. package/src/sdk/bus/index.ts +384 -92
  161. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  162. package/src/sdk/bus/notification-service.ts +284 -62
  163. package/src/sdk/bus/recent-activity.ts +14 -5
  164. package/src/sdk/bus/session-control.ts +150 -44
  165. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  166. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  167. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  168. package/src/sdk/bus/telegram-daemon.ts +1842 -281
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/session.ts +155 -27
  171. package/src/secrets/index.ts +53 -10
  172. package/src/secrets/obfuscator.ts +21 -18
  173. package/src/secrets/regex.ts +2 -0
  174. package/src/session/agent-session.ts +495 -253
  175. package/src/session/artifacts.ts +188 -23
  176. package/src/session/context-estimation.ts +7 -2
  177. package/src/session/internal/managed-session-scope.ts +215 -39
  178. package/src/session/internal/managed-session-storage.ts +1152 -46
  179. package/src/session/session-manager.ts +1055 -148
  180. package/src/session/session-storage.ts +109 -11
  181. package/src/session/streaming-output.ts +6 -0
  182. package/src/task/executor.ts +71 -26
  183. package/src/task/index.ts +63 -20
  184. package/src/tool-discovery/tool-index.ts +18 -0
  185. package/src/tools/ask.ts +329 -57
  186. package/src/tools/bash-allowed-prefixes.ts +79 -29
  187. package/src/tools/bash-skill-urls.ts +14 -3
  188. package/src/tools/bash.ts +2 -2
  189. package/src/tools/browser/launch.ts +3 -1
  190. package/src/tools/browser/readable.ts +1 -1
  191. package/src/tools/browser/screenshot-format.ts +34 -0
  192. package/src/tools/browser/tab-protocol.ts +3 -0
  193. package/src/tools/browser/tab-supervisor.ts +125 -122
  194. package/src/tools/browser/tab-worker-entry.ts +33 -14
  195. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  196. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  197. package/src/tools/browser/tab-worker.ts +7 -5
  198. package/src/tools/index.ts +4 -0
  199. package/src/tools/path-utils.ts +42 -35
  200. package/src/tools/plan-mode-guard.ts +1 -0
  201. package/src/tools/read.ts +7 -1
  202. package/src/tools/render-utils.ts +1 -27
  203. package/src/tools/resource-gc.ts +123 -34
  204. package/src/tools/sqlite-reader.ts +3 -2
  205. package/src/tools/subagent.ts +2 -0
  206. package/src/utils/image-loading.ts +85 -34
  207. package/src/utils/image-resize.ts +16 -9
  208. package/src/utils/pasted-image-loading.ts +514 -0
  209. package/src/utils/pasted-image-path.ts +223 -98
  210. package/src/web/scrapers/html-to-markdown.ts +68 -0
  211. package/src/web/scrapers/readthedocs.ts +1 -1
  212. package/src/web/scrapers/types.ts +1 -68
  213. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -12,12 +12,11 @@ import * as path from "node:path";
12
12
 
13
13
  import { isEnoent, logger, pathIsWithin } from "@gajae-code/utils";
14
14
 
15
- import { cachePlugin } from "./cache";
15
+ import { cachePlugin, getCachedPluginPath } from "./cache";
16
16
  import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
17
17
  import {
18
18
  addInstalledPlugin,
19
19
  addMarketplaceEntry,
20
- collectReferencedPaths,
21
20
  getInstalledPlugin,
22
21
  getMarketplaceEntry,
23
22
  readInstalledPluginsRegistry,
@@ -255,6 +254,7 @@ export class MarketplaceManager {
255
254
  if (existing && existing.length > 0 && !force) {
256
255
  throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`);
257
256
  }
257
+ const existingCachePaths = existing ? await this.#validateCacheDeletionTargets(pluginId, existing) : [];
258
258
 
259
259
  // 4. Resolve source path.
260
260
  // marketplaceClonePath is the marketplace root — the directory containing .Anthropic model-plugin/
@@ -289,6 +289,9 @@ export class MarketplaceManager {
289
289
  let cachePath!: string;
290
290
  try {
291
291
  version = await this.#resolvePluginVersion(pluginEntry, sourcePath);
292
+ if ([marketplace, name, version].some(component => /[. ]$/.test(component))) {
293
+ throw new Error(`Invalid cache identity for plugin "${pluginId}"`);
294
+ }
292
295
  cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version);
293
296
  await this.#writeEmbeddedLspConfig(pluginEntry, cachePath);
294
297
  } finally {
@@ -311,11 +314,13 @@ export class MarketplaceManager {
311
314
  ? readInstalledPluginsRegistry(this.#opts.projectInstalledRegistryPath)
312
315
  : Promise.resolve({ version: 2 as const, plugins: {} as Record<string, InstalledPluginEntry[]> }),
313
316
  ]);
314
- const referenced = collectReferencedPaths(userReg, projectReg);
317
+ const referenced = this.#collectReferencedCacheIdentities(userReg, projectReg);
318
+ const cachePathIdentity = this.#cacheDeletionIdentity(cachePath);
315
319
 
316
- for (const entry of existing) {
317
- if (entry.installPath !== cachePath && !referenced.has(entry.installPath)) {
318
- await fs.rm(entry.installPath, { recursive: true, force: true });
320
+ for (const existingCachePath of existingCachePaths) {
321
+ const existingIdentity = this.#cacheDeletionIdentity(existingCachePath);
322
+ if (existingIdentity !== cachePathIdentity && !referenced.has(existingIdentity)) {
323
+ await fs.rm(existingCachePath, { recursive: true, force: true });
319
324
  }
320
325
  }
321
326
  }
@@ -395,6 +400,84 @@ export class MarketplaceManager {
395
400
  return "0.0.0";
396
401
  }
397
402
 
403
+ async #validateCacheDeletionTargets(pluginId: string, entries: readonly InstalledPluginEntry[]): Promise<string[]> {
404
+ const parsed = parsePluginId(pluginId);
405
+ if (!parsed) {
406
+ throw new Error(`Invalid plugin ID format: "${pluginId}". Expected "name@marketplace".`);
407
+ }
408
+
409
+ const targets: string[] = [];
410
+ for (const entry of entries) {
411
+ if ([parsed.marketplace, parsed.name, entry.version].some(component => /[. ]$/.test(component))) {
412
+ throw new Error(`Refusing to remove plugin "${pluginId}": invalid recorded cache identity`);
413
+ }
414
+
415
+ let targetPath: string;
416
+ try {
417
+ targetPath = getCachedPluginPath(
418
+ this.#opts.pluginsCacheDir,
419
+ parsed.marketplace,
420
+ parsed.name,
421
+ entry.version,
422
+ );
423
+ } catch (error) {
424
+ throw new Error(`Refusing to remove plugin "${pluginId}": invalid recorded cache identity`, {
425
+ cause: error,
426
+ });
427
+ }
428
+
429
+ if (entry.installPath !== targetPath) {
430
+ throw new Error(`Refusing to remove plugin "${pluginId}": recorded install path is not its cache path`);
431
+ }
432
+
433
+ try {
434
+ const stat = await fs.lstat(targetPath);
435
+ if (stat.isSymbolicLink()) {
436
+ throw new Error(`Refusing to remove plugin "${pluginId}": cache path is a symbolic link or junction`);
437
+ }
438
+ } catch (error) {
439
+ if (!isEnoent(error)) throw error;
440
+ }
441
+
442
+ targets.push(targetPath);
443
+ }
444
+ return targets;
445
+ }
446
+
447
+ #cacheDeletionIdentity(cachePath: string): string {
448
+ // Model Windows path identity on every host so reference protection is
449
+ // deterministic: case is ignored and trailing dots/spaces are discarded.
450
+ return path
451
+ .resolve(cachePath)
452
+ .split(path.sep)
453
+ .map(component => component.replace(/[. ]+$/, "").toLowerCase())
454
+ .join(path.sep);
455
+ }
456
+
457
+ #collectReferencedCacheIdentities(...registries: InstalledPluginsRegistry[]): Set<string> {
458
+ const identities = new Set<string>();
459
+ for (const registry of registries) {
460
+ for (const [pluginId, entries] of Object.entries(registry.plugins)) {
461
+ const parsed = parsePluginId(pluginId);
462
+ if (!parsed) continue;
463
+ for (const entry of entries) {
464
+ try {
465
+ const cachePath = getCachedPluginPath(
466
+ this.#opts.pluginsCacheDir,
467
+ parsed.marketplace,
468
+ parsed.name,
469
+ entry.version,
470
+ );
471
+ identities.add(this.#cacheDeletionIdentity(cachePath));
472
+ } catch {
473
+ // Invalid registry entries cannot identify a permitted cache target.
474
+ }
475
+ }
476
+ }
477
+ }
478
+ return identities;
479
+ }
480
+
398
481
  async uninstallPlugin(pluginId: string, scope?: "user" | "project"): Promise<void> {
399
482
  const parsed = parsePluginId(pluginId);
400
483
  if (!parsed) {
@@ -434,6 +517,7 @@ export class MarketplaceManager {
434
517
  const targetEntries = targetScope === "project" ? projectEntries! : userEntries!;
435
518
  const targetReg = targetScope === "project" ? projectReg : userReg;
436
519
  const registryPath = this.#registryPath(targetScope);
520
+ const targetCachePaths = await this.#validateCacheDeletionTargets(pluginId, targetEntries);
437
521
 
438
522
  const updatedReg = removeInstalledPlugin(targetReg, pluginId);
439
523
  await writeInstalledPluginsRegistry(registryPath, updatedReg);
@@ -445,11 +529,11 @@ export class MarketplaceManager {
445
529
  ? readInstalledPluginsRegistry(this.#opts.projectInstalledRegistryPath)
446
530
  : Promise.resolve({ version: 2 as const, plugins: {} as Record<string, InstalledPluginEntry[]> }),
447
531
  ]);
448
- const referenced = collectReferencedPaths(freshUserReg, freshProjectReg);
532
+ const referenced = this.#collectReferencedCacheIdentities(freshUserReg, freshProjectReg);
449
533
 
450
- for (const entry of targetEntries) {
451
- if (!referenced.has(entry.installPath)) {
452
- await fs.rm(entry.installPath, { recursive: true, force: true });
534
+ for (const targetCachePath of targetCachePaths) {
535
+ if (!referenced.has(this.#cacheDeletionIdentity(targetCachePath))) {
536
+ await fs.rm(targetCachePath, { recursive: true, force: true });
453
537
  }
454
538
  }
455
539
 
@@ -8,7 +8,9 @@ import {
8
8
  } from "./deep-interview-ambiguity";
9
9
  import {
10
10
  answerHash,
11
+ assertDeepInterviewInputWithinLimit,
11
12
  assertDeepInterviewIntentManifest,
13
+ assertDeepInterviewStructuredResponseWithinLimit,
12
14
  createDeepInterviewIntentManifest,
13
15
  type DeepInterviewEstablishedFact,
14
16
  type DeepInterviewIntentItem,
@@ -17,6 +19,7 @@ import {
17
19
  type DeepInterviewStateEnvelope,
18
20
  type DeepInterviewTriggerMetadata,
19
21
  deriveRoundKey,
22
+ MAX_USER_RESPONSE_LENGTH,
20
23
  normalizeDeepInterviewEnvelope,
21
24
  questionHash,
22
25
  reviewDeepInterviewIntent,
@@ -426,6 +429,9 @@ export async function appendOrMergeDeepInterviewRound(
426
429
  input: DeepInterviewAnswerInput,
427
430
  options: { sessionId?: string } = {},
428
431
  ): Promise<{ action: AppendOrMergeAction; record: DeepInterviewRoundRecord; disputedFactIds?: string[] }> {
432
+ assertDeepInterviewStructuredResponseWithinLimit(input);
433
+ if (input.customInput !== undefined)
434
+ assertDeepInterviewInputWithinLimit(input.customInput, MAX_USER_RESPONSE_LENGTH, "user_response");
429
435
  const envelope = await readEnvelope(statePath);
430
436
  const interviewId = input.interviewId ?? interviewIdOf(envelope);
431
437
  const shell = buildAnswerShell({
@@ -547,6 +553,7 @@ export async function enrichDeepInterviewRoundScoring(
547
553
  input: DeepInterviewScoringInput,
548
554
  options: { sessionId?: string } = {},
549
555
  ): Promise<{ record: DeepInterviewRoundRecord }> {
556
+ assertDeepInterviewStructuredResponseWithinLimit(input);
550
557
  const envelope = await readEnvelope(statePath);
551
558
  const interviewId = input.interviewId ?? interviewIdOf(envelope);
552
559
  const rounds = readRounds(envelope);
@@ -7,11 +7,14 @@ import { syncSkillActiveState } from "../skill-state/active-state";
7
7
  import { deriveDeepInterviewHud } from "../skill-state/workflow-hud";
8
8
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
9
9
  import {
10
+ assertDeepInterviewInputWithinLimit,
10
11
  assertDeepInterviewIntentReview,
11
12
  type DeepInterviewIntentCategory,
12
13
  type DeepInterviewIntentItem,
13
14
  type DeepInterviewIntentManifest,
14
15
  type DeepInterviewIntentReview,
16
+ MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
17
+ MAX_INITIAL_CONTEXT_LENGTH,
15
18
  normalizeDeepInterviewEnvelope,
16
19
  reviewDeepInterviewIntent,
17
20
  } from "./deep-interview-state";
@@ -559,6 +562,7 @@ async function resolveDeepInterviewArgs(args: readonly string[], cwd: string): P
559
562
  ideaParts.push(arg);
560
563
  }
561
564
  const idea = ideaParts.join(" ").trim();
565
+ assertDeepInterviewInputWithinLimit(idea, MAX_INITIAL_CONTEXT_LENGTH, "initial_idea");
562
566
  const effectiveResolution: DeepInterviewResolution = resolution ?? "standard";
563
567
  const trace = hasFlag(args, "--trace") && idea ? await buildDeepInterviewTraceSummary(cwd, idea) : undefined;
564
568
  return {
@@ -632,6 +636,11 @@ export async function persistDeepInterviewSpec(
632
636
  cwd: string,
633
637
  resolved: ResolvedDeepInterviewSpecWriteArgs,
634
638
  ): Promise<PersistedDeepInterviewSpec> {
639
+ assertDeepInterviewInputWithinLimit(
640
+ resolved.spec,
641
+ MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH,
642
+ "structured deep-interview response",
643
+ );
635
644
  const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
636
645
  const existingRead = await readExistingStateForMutation(statePath);
637
646
  if (existingRead.kind === "corrupt" && !resolved.force) {
@@ -731,6 +740,7 @@ export async function persistDeepInterviewSpec(
731
740
 
732
741
  async function seedDeepInterviewState(cwd: string, resolved: ResolvedDeepInterviewArgs): Promise<string> {
733
742
  const statePath = deepInterviewStatePath(cwd, resolved.sessionId);
743
+ assertDeepInterviewInputWithinLimit(resolved.idea, MAX_INITIAL_CONTEXT_LENGTH, "initial_idea");
734
744
  const now = new Date().toISOString();
735
745
  const payload: Record<string, unknown> = {
736
746
  active: true,
@@ -397,6 +397,126 @@ const MAX_INTENT_STATEMENT_LENGTH = 1_000;
397
397
  const MAX_INTENT_RATIONALE_LENGTH = 500;
398
398
  const MAX_INTENT_EVIDENCE_LENGTH = 80;
399
399
 
400
+ // =============================================================================
401
+ // Input validation: free-text field allowlist + size limits (ouroboros parity)
402
+ // =============================================================================
403
+
404
+ /**
405
+ * User-input fields that legitimately carry prose (goals, prompts, descriptions,
406
+ * answers). Shell metacharacters (`;`, `|`, `&`, backticks, `$()`) are valid prose
407
+ * here and must NOT be rejected as structural injection. Structural fields (ids,
408
+ * categories, hashes) stay strictly validated by their own guards.
409
+ */
410
+ export const DEEP_INTERVIEW_FREETEXT_FIELDS: ReadonlySet<string> = new Set([
411
+ "initial_context",
412
+ "initial_idea",
413
+ "initial_context_summary",
414
+ "user_response",
415
+ "answer",
416
+ "goal",
417
+ "objective",
418
+ "prompt",
419
+ "description",
420
+ "statement",
421
+ "restated_goal",
422
+ "evidence",
423
+ "excerpt",
424
+ ]);
425
+
426
+ /** DoS-prevention character-count caps, matching ask-schema string-length semantics. */
427
+ export const MAX_INITIAL_CONTEXT_LENGTH = 50_000;
428
+ export const MAX_USER_RESPONSE_LENGTH = 10_000;
429
+ /** Maximum serialized JavaScript-string length for one LLM-produced structured response. */
430
+ export const MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH = 100_000;
431
+
432
+ /** Count Unicode code points without allocating an intermediate character array. */
433
+ export function deepInterviewCharacterCount(value: string): number {
434
+ let count = 0;
435
+ for (const _character of value) count++;
436
+ return count;
437
+ }
438
+
439
+ export function isDeepInterviewFreeTextField(name: string): boolean {
440
+ return DEEP_INTERVIEW_FREETEXT_FIELDS.has(name);
441
+ }
442
+
443
+ /**
444
+ * Assert that one structured deep-interview response is JSON-serializable and
445
+ * bounded by JavaScript string length. This deliberately does not measure or
446
+ * cap accumulated persisted interview state.
447
+ */
448
+ export function assertDeepInterviewStructuredResponseWithinLimit(value: unknown): void {
449
+ let serialized: string | undefined;
450
+ try {
451
+ serialized = JSON.stringify(value, (_key, nestedValue: unknown) => {
452
+ if (
453
+ typeof nestedValue === "bigint" ||
454
+ typeof nestedValue === "function" ||
455
+ typeof nestedValue === "symbol" ||
456
+ (typeof nestedValue === "number" && !Number.isFinite(nestedValue))
457
+ )
458
+ throw new Error("invalid structured deep-interview response");
459
+ return nestedValue;
460
+ });
461
+ } catch {
462
+ throw new Error("invalid structured deep-interview response");
463
+ }
464
+ if (typeof serialized !== "string") throw new Error("invalid structured deep-interview response");
465
+ if (deepInterviewCharacterCount(serialized) > MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH)
466
+ throw new Error(
467
+ `structured deep-interview response exceeds max length ${MAX_DEEP_INTERVIEW_STRUCTURED_RESPONSE_LENGTH}`,
468
+ );
469
+ }
470
+
471
+ /**
472
+ * Assert a free-text input is within its size cap. Never inspects content for shell
473
+ * metacharacters — free-text fields accept prose verbatim; this only bounds length.
474
+ */
475
+ export function assertDeepInterviewInputWithinLimit(value: string, max: number, fieldName = "input"): void {
476
+ if (typeof value !== "string") throw new Error(`${fieldName} must be a string`);
477
+ if (deepInterviewCharacterCount(value) > max) throw new Error(`${fieldName} exceeds max length ${max}`);
478
+ }
479
+
480
+ /** Validate user-supplied deep-interview prose before an envelope is persisted. */
481
+ export function assertDeepInterviewEnvelopeInputLimits(envelope: Record<string, unknown>): void {
482
+ const state =
483
+ typeof envelope.state === "object" && envelope.state !== null && !Array.isArray(envelope.state)
484
+ ? (envelope.state as Record<string, unknown>)
485
+ : {};
486
+ for (const field of ["initial_idea", "initial_context", "initial_context_summary"] as const) {
487
+ const nestedValue = state[field];
488
+ if (nestedValue !== undefined)
489
+ assertDeepInterviewInputWithinLimit(nestedValue as string, MAX_INITIAL_CONTEXT_LENGTH, `state.${field}`);
490
+ const topLevelValue = envelope[field];
491
+ if (topLevelValue !== undefined)
492
+ assertDeepInterviewInputWithinLimit(topLevelValue as string, MAX_INITIAL_CONTEXT_LENGTH, field);
493
+ }
494
+ for (const field of ["user_response", "answer"] as const) {
495
+ const nestedValue = state[field];
496
+ if (nestedValue !== undefined)
497
+ assertDeepInterviewInputWithinLimit(nestedValue as string, MAX_USER_RESPONSE_LENGTH, `state.${field}`);
498
+ const topLevelValue = envelope[field];
499
+ if (topLevelValue !== undefined)
500
+ assertDeepInterviewInputWithinLimit(topLevelValue as string, MAX_USER_RESPONSE_LENGTH, field);
501
+ }
502
+ if (!Array.isArray(state.rounds)) return;
503
+ for (const [index, round] of state.rounds.entries()) {
504
+ if (typeof round !== "object" || round === null || Array.isArray(round)) continue;
505
+ const record = round as Record<string, unknown>;
506
+ for (const field of ["custom_input", "customInput", "user_response", "answer"] as const) {
507
+ const value = record[field];
508
+ // A structured scorer may use an unrelated object-valued `answer`; only prose
509
+ // values in a legacy answer slot are user input subject to this cap.
510
+ if (value === undefined || (field === "answer" && typeof value !== "string")) continue;
511
+ assertDeepInterviewInputWithinLimit(
512
+ value as string,
513
+ MAX_USER_RESPONSE_LENGTH,
514
+ `state.rounds[${index}].${field}`,
515
+ );
516
+ }
517
+ }
518
+ }
519
+
400
520
  export interface DeepInterviewIntentItem {
401
521
  id: string;
402
522
  category: DeepInterviewIntentCategory;
@@ -437,7 +557,7 @@ export function isCanonicalDeepInterviewAnswerHash(value: unknown): value is str
437
557
  function isEvidenceReference(value: unknown, answerHashValue: string): value is string {
438
558
  return (
439
559
  typeof value === "string" &&
440
- value.length <= MAX_INTENT_EVIDENCE_LENGTH &&
560
+ deepInterviewCharacterCount(value) <= MAX_INTENT_EVIDENCE_LENGTH &&
441
561
  value === `answer_hash:${answerHashValue}`
442
562
  );
443
563
  }
@@ -451,7 +571,11 @@ function assertIntentItem(value: unknown): asserts value is DeepInterviewIntentI
451
571
  !id.startsWith(`${category}:`)
452
572
  )
453
573
  throw new Error("invalid intent category");
454
- if (typeof statement !== "string" || !statement.trim() || statement.length > MAX_INTENT_STATEMENT_LENGTH)
574
+ if (
575
+ typeof statement !== "string" ||
576
+ !statement.trim() ||
577
+ deepInterviewCharacterCount(statement) > MAX_INTENT_STATEMENT_LENGTH
578
+ )
455
579
  throw new Error(`intent item ${id} requires a bounded statement`);
456
580
  }
457
581
 
@@ -541,7 +665,7 @@ export function reviewDeepInterviewIntent(
541
665
  substitution.replacement_ids.some(id => typeof id !== "string" || !observedIds.has(id)) ||
542
666
  typeof substitution.rationale !== "string" ||
543
667
  !substitution.rationale.trim() ||
544
- substitution.rationale.length > MAX_INTENT_RATIONALE_LENGTH
668
+ deepInterviewCharacterCount(substitution.rationale) > MAX_INTENT_RATIONALE_LENGTH
545
669
  )
546
670
  throw new Error("invalid intent substitution");
547
671
  }
@@ -39,7 +39,11 @@ function renderRecord(record: GcRecord): string {
39
39
 
40
40
  export function buildGcReportText(report: GcReport): string {
41
41
  const lines: string[] = [];
42
- lines.push(report.dry_run ? "gjc gc — dry run (no changes made; pass --prune to remove)" : "gjc gc — prune");
42
+ if (report.operation === "repair_session_index") {
43
+ lines.push("gjc gc — session-index repair (other stores are report-only)");
44
+ } else {
45
+ lines.push(report.dry_run ? "gjc gc — dry run (no changes made; pass --prune to remove)" : "gjc gc — prune");
46
+ }
43
47
  lines.push("");
44
48
 
45
49
  for (const store of GC_STORES) {
@@ -53,6 +57,20 @@ export function buildGcReportText(report: GcReport): string {
53
57
  lines.push("");
54
58
  }
55
59
 
60
+ if (report.session_index) {
61
+ const index = report.session_index;
62
+ lines.push(`Session index: ${index.status}; valid prefix sequence=${index.valid_prefix_seq}`);
63
+ if (index.quarantine_path) lines.push(` Quarantined suffix: ${index.quarantine_path}`);
64
+ if (index.reason) lines.push(` ${index.reason}`);
65
+ if (index.status === "corrupt")
66
+ lines.push(" Run `gjc gc --repair-session-index` to quarantine the corrupt suffix.");
67
+ if (index.status === "unsupported")
68
+ lines.push(" Upgrade GJC before attempting a repair; no index data was changed.");
69
+ if (index.status === "repaired")
70
+ lines.push(" Restart or re-register hosts whose only registration was in the quarantined suffix.");
71
+ lines.push("");
72
+ }
73
+
56
74
  if (report.errors.length > 0) {
57
75
  lines.push(`Errors (${report.errors.length})`);
58
76
  for (const err of report.errors) lines.push(` [${err.store}/${err.scope}] ${err.message}`);
@@ -13,6 +13,10 @@
13
13
  * - Dry-run by default: nothing is deleted unless `--prune`/`--force`.
14
14
  */
15
15
 
16
+ import { getAgentDir } from "@gajae-code/utils";
17
+ import { SessionIndex } from "../sdk/broker/session-index";
18
+ import { UnsupportedStateVersionError } from "../sdk/broker/state-version";
19
+
16
20
  import { buildGcReportText } from "./gc-render";
17
21
 
18
22
  export type GcStore = "harness_leases" | "team_workers" | "file_locks" | "tmux_sessions" | "registry_entries";
@@ -115,11 +119,21 @@ export interface GcCounts {
115
119
  >;
116
120
  }
117
121
 
122
+ export interface GcSessionIndexHealth {
123
+ status: "healthy" | "corrupt" | "repaired" | "unsupported" | "repair_failed";
124
+ valid_prefix_seq: number;
125
+ snapshot_seq?: number;
126
+ reason?: string;
127
+ quarantine_path?: string;
128
+ }
129
+
118
130
  export interface GcReport {
119
131
  dry_run: boolean;
132
+ operation?: "dry_run" | "prune" | "repair_session_index";
120
133
  stores: Record<GcStore, GcRecord[]>;
121
134
  counts: GcCounts;
122
135
  errors: GcError[];
136
+ session_index?: GcSessionIndexHealth;
123
137
  }
124
138
 
125
139
  export interface GcRunResult {
@@ -225,6 +239,7 @@ function computeCounts(stores: Record<GcStore, GcRecord[]>, errors: GcError[]):
225
239
  interface ParsedGcArgs {
226
240
  json: boolean;
227
241
  prune: boolean;
242
+ repairSessionIndex: boolean;
228
243
  help: boolean;
229
244
  }
230
245
 
@@ -233,6 +248,8 @@ class GcUsageError extends Error {}
233
248
  function parseGcArgs(argv: string[]): ParsedGcArgs {
234
249
  let json = false;
235
250
  let prune = false;
251
+ let repairSessionIndex = false;
252
+
236
253
  let dryRun = false;
237
254
  let help = false;
238
255
  for (const arg of argv) {
@@ -245,6 +262,10 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
245
262
  case "--force":
246
263
  prune = true;
247
264
  break;
265
+ case "--repair-session-index":
266
+ repairSessionIndex = true;
267
+ break;
268
+
248
269
  case "--dry-run":
249
270
  dryRun = true;
250
271
  break;
@@ -256,9 +277,11 @@ function parseGcArgs(argv: string[]): ParsedGcArgs {
256
277
  throw new GcUsageError(`unknown_flag:${arg}`);
257
278
  }
258
279
  }
280
+ if (repairSessionIndex && prune) throw new GcUsageError("repair_session_index_cannot_combine_with_prune");
281
+ if (repairSessionIndex && dryRun) throw new GcUsageError("repair_session_index_cannot_combine_with_dry_run");
259
282
  // Explicit --dry-run always wins over --prune/--force.
260
283
  if (dryRun) prune = false;
261
- return { json, prune, help };
284
+ return { json, prune, repairSessionIndex, help };
262
285
  }
263
286
 
264
287
  /**
@@ -336,6 +359,40 @@ export function computeExitCode(report: GcReport): number {
336
359
  return 0;
337
360
  }
338
361
 
362
+ function resolveGcAgentDir(env: NodeJS.ProcessEnv): string {
363
+ return env.GJC_CODING_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || getAgentDir();
364
+ }
365
+
366
+ async function collectSessionIndexHealth(repair: boolean, agentDir: string): Promise<GcSessionIndexHealth> {
367
+ const index = new SessionIndex(agentDir);
368
+ try {
369
+ if (repair) {
370
+ const result = await index.repair();
371
+ return {
372
+ status: result.status === "unsupported" ? "unsupported" : result.repaired ? "repaired" : "healthy",
373
+ valid_prefix_seq: result.validPrefixSeq,
374
+ snapshot_seq: result.snapshotSeq,
375
+ ...(result.reason ? { reason: result.reason } : {}),
376
+ ...(result.quarantinePath ? { quarantine_path: result.quarantinePath } : {}),
377
+ };
378
+ }
379
+ const diagnosis = await index.diagnose();
380
+ return {
381
+ status: diagnosis.status,
382
+ valid_prefix_seq: diagnosis.validPrefixSeq,
383
+ snapshot_seq: diagnosis.snapshotSeq,
384
+ ...(diagnosis.reason ? { reason: diagnosis.reason } : {}),
385
+ };
386
+ } catch (error) {
387
+ const reason = error instanceof Error ? error.message : String(error);
388
+ return {
389
+ status: error instanceof UnsupportedStateVersionError ? "unsupported" : "repair_failed",
390
+ valid_prefix_seq: 0,
391
+ reason,
392
+ };
393
+ }
394
+ }
395
+
339
396
  export async function runGjcGcCommand(
340
397
  argv: string[],
341
398
  cwd: string = process.cwd(),
@@ -357,7 +414,13 @@ export async function runGjcGcCommand(
357
414
  const resolvedAdapters = adapters ?? (await defaultGcAdapters());
358
415
  const ctx: GcContext = { probe: gcPidProbe, force: parsed.prune, env, cwd };
359
416
  const report = await collectGcReport(resolvedAdapters, ctx, parsed.prune);
360
- const status = computeExitCode(report);
417
+ report.operation = parsed.repairSessionIndex ? "repair_session_index" : parsed.prune ? "prune" : "dry_run";
418
+ report.session_index = await collectSessionIndexHealth(parsed.repairSessionIndex, resolveGcAgentDir(env));
419
+ const sessionIndexFailed =
420
+ report.session_index?.status === "corrupt" ||
421
+ report.session_index?.status === "unsupported" ||
422
+ report.session_index?.status === "repair_failed";
423
+ const status = sessionIndexFailed ? 1 : computeExitCode(report);
361
424
  const stdout = parsed.json ? `${JSON.stringify(report, null, 2)}\n` : buildGcReportText(report);
362
425
  return { stdout, stderr: "", status };
363
426
  }
@@ -367,12 +430,14 @@ export function gcHelpText(): string {
367
430
  "gjc gc - garbage-collect stale GJC session/PID records",
368
431
  "",
369
432
  "USAGE",
370
- " $ gjc gc [--prune|--force] [--json]",
433
+ " $ gjc gc [--prune|--force] [--repair-session-index] [--json]",
434
+
371
435
  "",
372
436
  "FLAGS",
373
437
  " --prune, --force Actually remove stale records (default: dry-run report only)",
374
438
  " --dry-run Force report-only mode (overrides --prune/--force)",
375
439
  " -j, --json Emit machine-readable JSON",
440
+ " --repair-session-index Explicitly quarantine a corrupt session-index suffix and retain its valid prefix",
376
441
  "",
377
442
  "Liveness-only: a record is removed only when its owning process is dead",
378
443
  "(ESRCH). Live / permission-denied / unknown processes are always kept.",