@gajae-code/coding-agent 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +26 -0
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/list-models.d.ts +6 -0
  6. package/dist/types/cli/setup-cli.d.ts +8 -1
  7. package/dist/types/commands/gc.d.ts +26 -0
  8. package/dist/types/commands/setup.d.ts +7 -0
  9. package/dist/types/config/file-lock-gc.d.ts +5 -0
  10. package/dist/types/config/file-lock.d.ts +29 -0
  11. package/dist/types/config/model-registry.d.ts +4 -0
  12. package/dist/types/config/models-config-schema.d.ts +5 -0
  13. package/dist/types/config/settings-schema.d.ts +62 -0
  14. package/dist/types/coordinator/contract.d.ts +1 -1
  15. package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
  16. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
  17. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
  18. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
  19. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
  24. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
  25. package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
  26. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  27. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
  28. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
  29. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
  31. package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
  32. package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
  33. package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
  34. package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
  35. package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
  36. package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +11 -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/gjc-runtime/ultragoal-guard.d.ts +10 -0
  41. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
  42. package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
  43. package/dist/types/harness-control-plane/owner.d.ts +7 -0
  44. package/dist/types/harness-control-plane/storage.d.ts +20 -0
  45. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  46. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  48. package/dist/types/modes/interactive-mode.d.ts +1 -1
  49. package/dist/types/modes/rpc/rpc-mode.d.ts +72 -2
  50. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
  51. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
  52. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
  53. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  54. package/dist/types/modes/theme/defaults/index.d.ts +302 -0
  55. package/dist/types/modes/theme/theme.d.ts +1 -0
  56. package/dist/types/modes/types.d.ts +1 -1
  57. package/dist/types/session/agent-session.d.ts +1 -1
  58. package/dist/types/session/blob-store.d.ts +39 -3
  59. package/dist/types/session/history-storage.d.ts +2 -2
  60. package/dist/types/session/session-manager.d.ts +10 -1
  61. package/dist/types/setup/credential-import.d.ts +79 -0
  62. package/dist/types/skill-state/workflow-hud.d.ts +14 -0
  63. package/dist/types/task/executor.d.ts +1 -0
  64. package/dist/types/task/render.d.ts +1 -1
  65. package/dist/types/tools/ask.d.ts +15 -1
  66. package/dist/types/tools/subagent-render.d.ts +7 -1
  67. package/dist/types/tools/subagent.d.ts +27 -0
  68. package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
  69. package/dist/types/web/search/index.d.ts +4 -4
  70. package/dist/types/web/search/provider.d.ts +16 -20
  71. package/dist/types/web/search/providers/base.d.ts +2 -1
  72. package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
  73. package/dist/types/web/search/types.d.ts +14 -2
  74. package/package.json +7 -7
  75. package/scripts/build-binary.ts +7 -0
  76. package/src/async/job-manager.ts +52 -0
  77. package/src/cli/args.ts +5 -0
  78. package/src/cli/auth-broker-cli.ts +1 -0
  79. package/src/cli/fast-help.ts +2 -0
  80. package/src/cli/list-models.ts +13 -1
  81. package/src/cli/setup-cli.ts +138 -3
  82. package/src/cli.ts +1 -0
  83. package/src/commands/gc.ts +22 -0
  84. package/src/commands/harness.ts +7 -3
  85. package/src/commands/setup.ts +5 -1
  86. package/src/commands/ultragoal.ts +3 -1
  87. package/src/config/file-lock-gc.ts +193 -0
  88. package/src/config/file-lock.ts +66 -10
  89. package/src/config/model-profile-activation.ts +15 -3
  90. package/src/config/model-profiles.ts +39 -30
  91. package/src/config/model-registry.ts +21 -1
  92. package/src/config/models-config-schema.ts +1 -0
  93. package/src/config/settings-schema.ts +62 -0
  94. package/src/coordinator/contract.ts +1 -0
  95. package/src/coordinator-mcp/server.ts +459 -3
  96. package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
  97. package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
  98. package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
  99. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
  100. package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
  101. package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
  102. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
  103. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
  104. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
  105. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
  106. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
  107. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
  108. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
  109. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
  110. package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
  111. package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
  112. package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
  113. package/src/defaults/gjc-defaults.ts +7 -0
  114. package/src/defaults/gjc-grok-cli.ts +22 -0
  115. package/src/extensibility/extensions/index.ts +1 -0
  116. package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
  117. package/src/gjc-runtime/deep-interview-recorder.ts +457 -0
  118. package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
  119. package/src/gjc-runtime/deep-interview-state.ts +324 -0
  120. package/src/gjc-runtime/gc-render.ts +70 -0
  121. package/src/gjc-runtime/gc-runtime.ts +403 -0
  122. package/src/gjc-runtime/launch-tmux.ts +3 -4
  123. package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
  124. package/src/gjc-runtime/ralplan-runtime.ts +232 -19
  125. package/src/gjc-runtime/state-renderer.ts +12 -3
  126. package/src/gjc-runtime/state-runtime.ts +48 -30
  127. package/src/gjc-runtime/state-writer.ts +254 -7
  128. package/src/gjc-runtime/team-gc.ts +49 -0
  129. package/src/gjc-runtime/team-runtime.ts +179 -2
  130. package/src/gjc-runtime/tmux-common.ts +14 -0
  131. package/src/gjc-runtime/tmux-gc.ts +177 -0
  132. package/src/gjc-runtime/tmux-sessions.ts +49 -1
  133. package/src/gjc-runtime/ultragoal-guard.ts +155 -0
  134. package/src/gjc-runtime/ultragoal-runtime.ts +1239 -31
  135. package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
  136. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  137. package/src/harness-control-plane/gc-adapter.ts +184 -0
  138. package/src/harness-control-plane/owner.ts +14 -2
  139. package/src/harness-control-plane/rpc-adapter.ts +1 -1
  140. package/src/harness-control-plane/storage.ts +70 -0
  141. package/src/hooks/skill-state.ts +121 -2
  142. package/src/internal-urls/docs-index.generated.ts +22 -12
  143. package/src/lsp/defaults.json +1 -0
  144. package/src/main.ts +18 -3
  145. package/src/modes/acp/acp-agent.ts +4 -2
  146. package/src/modes/bridge/bridge-mode.ts +2 -1
  147. package/src/modes/components/history-search.ts +5 -2
  148. package/src/modes/components/hook-selector.ts +19 -0
  149. package/src/modes/components/model-selector.ts +51 -8
  150. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  151. package/src/modes/components/status-line/segments.ts +1 -1
  152. package/src/modes/controllers/command-controller.ts +25 -6
  153. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  154. package/src/modes/controllers/selector-controller.ts +81 -1
  155. package/src/modes/interactive-mode.ts +11 -1
  156. package/src/modes/rpc/rpc-mode.ts +266 -34
  157. package/src/modes/shared/agent-wire/command-dispatch.ts +281 -261
  158. package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
  159. package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
  160. package/src/modes/shared/agent-wire/session-registry.ts +109 -0
  161. package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
  162. package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
  163. package/src/modes/shared/agent-wire/unattended-session.ts +32 -2
  164. package/src/modes/theme/defaults/claude-code.json +100 -0
  165. package/src/modes/theme/defaults/codex.json +100 -0
  166. package/src/modes/theme/defaults/index.ts +6 -0
  167. package/src/modes/theme/defaults/opencode.json +102 -0
  168. package/src/modes/theme/theme.ts +2 -2
  169. package/src/modes/types.ts +1 -1
  170. package/src/prompts/agents/executor.md +5 -2
  171. package/src/sdk.ts +29 -4
  172. package/src/session/agent-session.ts +99 -19
  173. package/src/session/blob-store.ts +59 -3
  174. package/src/session/history-storage.ts +32 -11
  175. package/src/session/session-manager.ts +72 -20
  176. package/src/setup/credential-import.ts +429 -0
  177. package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
  178. package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
  179. package/src/skill-state/workflow-hud.ts +106 -10
  180. package/src/slash-commands/builtin-registry.ts +3 -2
  181. package/src/task/executor.ts +16 -1
  182. package/src/task/render.ts +18 -7
  183. package/src/tools/ask.ts +59 -2
  184. package/src/tools/cron.ts +1 -1
  185. package/src/tools/job.ts +3 -2
  186. package/src/tools/monitor.ts +36 -1
  187. package/src/tools/subagent-render.ts +128 -29
  188. package/src/tools/subagent.ts +173 -9
  189. package/src/tools/ultragoal-ask-guard.ts +39 -0
  190. package/src/web/search/index.ts +25 -25
  191. package/src/web/search/provider.ts +178 -87
  192. package/src/web/search/providers/base.ts +2 -1
  193. package/src/web/search/providers/openai-compatible.ts +151 -0
  194. package/src/web/search/types.ts +47 -22
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import type { Stats } from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
5
+ import { type FileLockOptions, withFileLock } from "../config/file-lock";
4
6
  import type { ActiveSubskillEntry, SkillActiveEntry, SkillActiveState } from "../skill-state/active-state";
5
7
  import {
6
8
  type AuditEntry,
@@ -80,6 +82,12 @@ export interface StateWriterOptions {
80
82
  cwd?: string;
81
83
  receipt?: StateWriterReceiptContext;
82
84
  audit?: StateWriterAuditContext;
85
+ /**
86
+ * Cross-process lock tuning for read-modify-write paths that route through
87
+ * `withWorkflowStateLock` / `updateJsonAtomic`. Omit for the hardened
88
+ * `withFileLock` defaults.
89
+ */
90
+ lock?: FileLockOptions;
83
91
  }
84
92
 
85
93
  export interface DeleteIfOwnedOptions extends StateWriterOptions {
@@ -113,7 +121,7 @@ export interface GenericHardPruneTarget {
113
121
  export interface GenericHardPruneSelectorContext {
114
122
  path: string;
115
123
  category: WriterCategory | string;
116
- stat: Awaited<ReturnType<typeof fs.stat>>;
124
+ stat: Stats;
117
125
  readJson: () => Promise<unknown>;
118
126
  }
119
127
 
@@ -388,6 +396,57 @@ export async function writeJsonAtomic(
388
396
  return filePath;
389
397
  }
390
398
 
399
+ async function readPersistedPhase(filePath: string): Promise<string | undefined> {
400
+ try {
401
+ const existing = await readJsonIfPresent(filePath);
402
+ if (!isPlainObject(existing)) return undefined;
403
+ // Only an *active* prior envelope is a transition source. A cleared / handed-off
404
+ // envelope (`active: false`, terminal phase such as `complete` / `handoff`) is outside
405
+ // active workflow progression, so reactivation from it (e.g. a fresh kickoff) must not
406
+ // be reported as an invalid transition.
407
+ if (existing.active !== true) return undefined;
408
+ const phase = existing.current_phase;
409
+ return typeof phase === "string" ? phase : undefined;
410
+ } catch {
411
+ // Best-effort diagnostic read: a corrupt/unreadable prior envelope simply yields no
412
+ // `from` phase, so the transition invariant degrades to a no-op rather than failing
413
+ // the sanctioned write it is observing.
414
+ return undefined;
415
+ }
416
+ }
417
+
418
+ async function recordInvalidWorkflowTransition(args: {
419
+ filePath: string;
420
+ skill: CanonicalGjcWorkflowSkill;
421
+ fromPhase: string;
422
+ toPhase: string;
423
+ options?: StateWriterOptions;
424
+ }): Promise<void> {
425
+ const { filePath, skill, fromPhase, toPhase, options } = args;
426
+ // Audit-only diagnostic: a successful sanctioned write must NOT emit to stderr — callers
427
+ // may treat any stderr output as failure or parse stdout/stderr as machine output. The
428
+ // `invalid_transition_detected` audit entry is the durable, non-intrusive evidence that an
429
+ // internal write skipped a manifest edge.
430
+ const cwd = path.resolve(options?.audit?.cwd ?? options?.cwd ?? process.cwd());
431
+ try {
432
+ await appendAuditEntry(cwd, {
433
+ ts: new Date().toISOString(),
434
+ skill,
435
+ category: "state",
436
+ verb: "invalid_transition_detected",
437
+ owner: options?.audit?.owner ?? "gjc-runtime",
438
+ mutation_id: options?.audit?.mutationId ?? `${skill}:invalid-transition:${new Date().toISOString()}`,
439
+ from_phase: fromPhase,
440
+ to_phase: toPhase,
441
+ forced: false,
442
+ paths: [filePath],
443
+ });
444
+ } catch {
445
+ // Audit logging is best-effort diagnostics; never fail a sanctioned write because the
446
+ // audit append failed (e.g. cwd is not a writable project root).
447
+ }
448
+ }
449
+
391
450
  export async function writeWorkflowEnvelopeAtomic(
392
451
  targetPath: string,
393
452
  value: unknown,
@@ -404,6 +463,50 @@ export async function writeWorkflowEnvelopeAtomic(
404
463
  .join("; ")}`,
405
464
  );
406
465
  }
466
+ // #658: internal runtime writers (ralplan/ultragoal/deep-interview/team) persist
467
+ // envelopes directly, bypassing the `gjc state` CLI transition gate (`isValidTransition`,
468
+ // historically the sole call site in state-runtime.ts). Re-assert that gate on every
469
+ // sanctioned envelope write so internal writes cannot persist invalid state-machine phase
470
+ // transitions silently. Forced writes (`gjc state ... --force`, reconcile repairs) carry
471
+ // `audit.forced` and bypass, mirroring the CLI's `use --force to bypass`.
472
+ //
473
+ // The gate governs ACTIVE workflow progression only. Deactivation/teardown writes
474
+ // (`active: false`, e.g. `gjc state clear`, which persists the universal `complete`
475
+ // sentinel that is not a per-skill manifest state) leave the transition graph and are
476
+ // intentionally exempt.
477
+ if (options?.audit?.forced !== true && parsed.data.active === true) {
478
+ const toPhase = parsed.data.current_phase.trim();
479
+ if (toPhase) {
480
+ // Lazy import: workflow-manifest dereferences CANONICAL_GJC_WORKFLOW_SKILLS at
481
+ // module load, and active-state -> state-writer -> workflow-manifest -> active-state
482
+ // is a load-time cycle. Importing at call time (after init) avoids the TDZ.
483
+ const { isKnownWorkflowState, isValidTransition } = await import("./workflow-manifest");
484
+ const skill = parsed.data.skill;
485
+ // Structural invariant (hard): a `current_phase` absent from the skill's manifest is
486
+ // never a legitimate internal write, matching the CLI/reconcile unknown-phase gate.
487
+ if (!isKnownWorkflowState(skill, toPhase)) {
488
+ throw new Error(
489
+ `Refusing to write unknown ${skill} phase "${toPhase}" to ${filePath}: not a known ${skill} manifest state (forced writes bypass via audit.forced)`,
490
+ );
491
+ }
492
+ // Transition invariant (#658, diagnostic-only safety net): resolve the prior phase
493
+ // (caller-supplied `audit.fromPhase`, else the active persisted envelope on disk) and
494
+ // flag edges the manifest does not define. Intentionally NON-blocking and audit-only
495
+ // — the CLI path already hard-fails invalid edges before reaching here, and legitimate
496
+ // internal repairs / ralplan short-mode stage skips move between valid states without a
497
+ // direct manifest edge. It records an `invalid_transition_detected` audit entry (no
498
+ // stderr) so such transitions are non-silent without breaking those flows.
499
+ const fromPhase = (options?.audit?.fromPhase ?? (await readPersistedPhase(filePath)))?.trim();
500
+ if (
501
+ fromPhase &&
502
+ fromPhase !== toPhase &&
503
+ isKnownWorkflowState(skill, fromPhase) &&
504
+ !isValidTransition(skill, fromPhase, toPhase)
505
+ ) {
506
+ await recordInvalidWorkflowTransition({ filePath, skill, fromPhase, toPhase, options });
507
+ }
508
+ }
509
+ }
407
510
  await atomicWrite(filePath, jsonText(stamped));
408
511
  await maybeAudit(filePath, options);
409
512
  return filePath;
@@ -416,17 +519,55 @@ export async function writeTextAtomic(targetPath: string, text: string, options?
416
519
  return filePath;
417
520
  }
418
521
 
522
+ /**
523
+ * Serialize a read-modify-write (or any multi-step mutation) against concurrent
524
+ * writers of the same `.gjc/**` target. Uses the cross-process directory lock
525
+ * from `withFileLock`, keyed on the resolved file path, so separate CLI/agent
526
+ * processes (e.g. team-mode workers) cannot interleave one writer's read with
527
+ * another writer's write and silently drop the first mutation (issue #646).
528
+ *
529
+ * The lock is advisory: it only protects callers that route through it, so every
530
+ * read-modify-write of a given file MUST acquire this lock for the same resolved
531
+ * path. `atomicWrite`'s temp-file + rename crash-atomicity is preserved; this
532
+ * layers concurrency-atomicity on top without weakening it.
533
+ */
534
+ export async function withWorkflowStateLock<T>(
535
+ targetPath: string,
536
+ fn: () => Promise<T>,
537
+ options?: StateWriterOptions,
538
+ ): Promise<T> {
539
+ const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
540
+ return lockResolvedWorkflowTarget(filePath, fn, options?.lock);
541
+ }
542
+
543
+ async function lockResolvedWorkflowTarget<T>(
544
+ filePath: string,
545
+ fn: () => Promise<T>,
546
+ lockOptions?: FileLockOptions,
547
+ ): Promise<T> {
548
+ // `withFileLock` creates the lock dir next to the target with a non-recursive
549
+ // mkdir, so the parent directory must exist before the lock is acquired.
550
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
551
+ return withFileLock(filePath, fn, lockOptions);
552
+ }
553
+
419
554
  export async function updateJsonAtomic<T = unknown>(
420
555
  targetPath: string,
421
556
  mutator: (current: T | undefined) => T | Promise<T>,
422
557
  options?: StateWriterOptions,
423
558
  ): Promise<string> {
424
559
  const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
425
- const current = (await readJsonIfPresent(filePath)) as T | undefined;
426
- const next = await mutator(current);
427
- await atomicWrite(filePath, jsonText(withWorkflowReceipt(next, buildReceipt(options))));
428
- await maybeAudit(filePath, options);
429
- return filePath;
560
+ return lockResolvedWorkflowTarget(
561
+ filePath,
562
+ async () => {
563
+ const current = (await readJsonIfPresent(filePath)) as T | undefined;
564
+ const next = await mutator(current);
565
+ await atomicWrite(filePath, jsonText(withWorkflowReceipt(next, buildReceipt(options))));
566
+ await maybeAudit(filePath, options);
567
+ return filePath;
568
+ },
569
+ options?.lock,
570
+ );
430
571
  }
431
572
 
432
573
  export async function appendJsonl(targetPath: string, entry: unknown, options?: StateWriterOptions): Promise<string> {
@@ -437,6 +578,112 @@ export async function appendJsonl(targetPath: string, entry: unknown, options?:
437
578
  return filePath;
438
579
  }
439
580
 
581
+ export interface AppendJsonlIdempotentOptions extends StateWriterOptions {
582
+ /**
583
+ * Identity key for an entry. Two entries that produce the same non-`undefined`
584
+ * key are duplicates, so only the first is appended. Return `undefined` to opt a
585
+ * candidate out of dedup (it is always appended). Use `key` for the common case
586
+ * where identity reduces to a single string.
587
+ */
588
+ key?: (entry: unknown) => string | undefined;
589
+ /**
590
+ * Equivalence predicate: return `true` when `existing` already represents
591
+ * `candidate`, suppressing the append. Use when identity cannot be reduced to a
592
+ * single string key. When both `key` and `equals` are supplied, `equals` wins.
593
+ */
594
+ equals?: (candidate: unknown, existing: unknown) => boolean;
595
+ }
596
+
597
+ export interface AppendJsonlIdempotentResult {
598
+ path: string;
599
+ /** `true` when the entry was written; `false` when an equivalent entry already existed. */
600
+ appended: boolean;
601
+ /** The pre-existing entry that suppressed the append, when `appended` is `false`. */
602
+ duplicate?: unknown;
603
+ }
604
+
605
+ async function readJsonlEntries(filePath: string): Promise<unknown[]> {
606
+ let raw: string;
607
+ try {
608
+ raw = await fs.readFile(filePath, "utf-8");
609
+ } catch (error) {
610
+ if (isErrno(error, "ENOENT")) return [];
611
+ throw error;
612
+ }
613
+ const entries: unknown[] = [];
614
+ for (const line of raw.split(/\r?\n/)) {
615
+ const trimmed = line.trim();
616
+ if (!trimmed) continue;
617
+ try {
618
+ entries.push(JSON.parse(trimmed));
619
+ } catch {
620
+ // Best-effort: dedup compares parseable rows only. A corrupt line cannot
621
+ // be matched, so it never suppresses a new append.
622
+ }
623
+ }
624
+ return entries;
625
+ }
626
+
627
+ function findJsonlDuplicate(
628
+ existing: readonly unknown[],
629
+ candidate: unknown,
630
+ options: AppendJsonlIdempotentOptions,
631
+ ): unknown | undefined {
632
+ if (options.equals) {
633
+ const equals = options.equals;
634
+ return existing.find(item => equals(candidate, item));
635
+ }
636
+ const key = options.key;
637
+ if (!key) return undefined;
638
+ const candidateKey = key(candidate);
639
+ if (candidateKey === undefined) return undefined;
640
+ return existing.find(item => key(item) === candidateKey);
641
+ }
642
+
643
+ /**
644
+ * Append `entry` to a JSONL file only when no equivalent entry already exists —
645
+ * the shared idempotent append primitive (issue #660).
646
+ *
647
+ * `appendJsonl` is a pure append with no dedup, so every recurring "duplicate
648
+ * ledger row" bug (#638, #643, #645) had to be patched with bespoke per-call-site
649
+ * guards. This primitive centralizes the read-check-append cycle: a caller
650
+ * declares identity once via `key` or `equals` instead of re-deriving the lookup
651
+ * at each site.
652
+ *
653
+ * The read-then-append is serialized through the same cross-process workflow lock
654
+ * as `updateJsonAtomic`, so two concurrent idempotent appends cannot both observe
655
+ * "no duplicate" and both write (the #646 TOCTOU that a plain `appendJsonl`
656
+ * preceded by a manual existence check is still exposed to).
657
+ *
658
+ * Scope note: this dedups the *append* only. Call sites whose idempotency must
659
+ * also skip a coupled mutation — e.g. the plan/state rewrite in #643/#645 — still
660
+ * need a whole-operation guard; this primitive is the ledger-level half of that.
661
+ */
662
+ export async function appendJsonlIdempotent(
663
+ targetPath: string,
664
+ entry: unknown,
665
+ options: AppendJsonlIdempotentOptions,
666
+ ): Promise<AppendJsonlIdempotentResult> {
667
+ if (!options.key && !options.equals) {
668
+ throw new Error("appendJsonlIdempotent requires a `key` or `equals` option to detect duplicates");
669
+ }
670
+ const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
671
+ return lockResolvedWorkflowTarget(
672
+ filePath,
673
+ async () => {
674
+ const existing = await readJsonlEntries(filePath);
675
+ const duplicate = findJsonlDuplicate(existing, entry, options);
676
+ if (duplicate !== undefined) {
677
+ return { path: filePath, appended: false, duplicate };
678
+ }
679
+ await fs.appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf-8");
680
+ await maybeAudit(filePath, options);
681
+ return { path: filePath, appended: true };
682
+ },
683
+ options.lock,
684
+ );
685
+ }
686
+
440
687
  export async function appendText(targetPath: string, text: string, options?: StateWriterOptions): Promise<string> {
441
688
  const filePath = resolveGjcTarget(targetPath, cwdForOptions(options));
442
689
  await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -639,7 +886,7 @@ export async function hardPrune(
639
886
  const removed: string[] = [];
640
887
  for (const target of targets) {
641
888
  const filePath = resolveGjcTarget(target.path, cwd);
642
- let stat: Awaited<ReturnType<typeof fs.stat>>;
889
+ let stat: Stats;
643
890
  try {
644
891
  stat = await fs.stat(filePath);
645
892
  } catch (error) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GC adapter for team workers (`.gjc/state/team/<name>/workers/<id>/` heartbeat
3
+ * + lifecycle). Liveness-only: numeric PID status dominates lifecycle/heartbeat
4
+ * signals.
5
+ */
6
+
7
+ import * as path from "node:path";
8
+ import { listHarnessRootRegistriesForGc } from "../harness-control-plane/storage";
9
+ import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime";
10
+ import { listTeamWorkerGcRecords, pruneTeamWorkerGcRecord } from "./team-runtime";
11
+
12
+ function uniqueTeamRootsFromHarnessRoots(roots: string[]): string[] {
13
+ return [...new Set(roots.map(root => path.join(path.dirname(root), "team")))].sort();
14
+ }
15
+
16
+ export const teamWorkersGcAdapter: GcStoreAdapter = {
17
+ store: "team_workers",
18
+ async collect(ctx: GcContext): Promise<GcCollectResult> {
19
+ const records: GcRecord[] = [];
20
+ const errors: GcCollectResult["errors"] = [];
21
+ const registries = await listHarnessRootRegistriesForGc(ctx.env);
22
+ for (const registry of registries) {
23
+ if (registry.error) errors.push({ store: "team_workers", scope: registry.file, message: registry.error });
24
+ }
25
+
26
+ const teamRoots = uniqueTeamRootsFromHarnessRoots(
27
+ registries.flatMap(registry => registry.roots.map(entry => entry.root)),
28
+ );
29
+ for (const teamRoot of teamRoots) {
30
+ try {
31
+ records.push(...(await listTeamWorkerGcRecords(teamRoot, ctx.probe)));
32
+ } catch (error) {
33
+ const code = (error as NodeJS.ErrnoException).code;
34
+ if (code === "ENOENT") continue;
35
+ errors.push({ store: "team_workers", scope: teamRoot, message: (error as Error).message });
36
+ }
37
+ }
38
+
39
+ return { records, errors };
40
+ },
41
+ async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
42
+ try {
43
+ const removed = await pruneTeamWorkerGcRecord(record, ctx.probe);
44
+ return removed ? { removed: true } : { removed: false, skipped: "worker_no_longer_dead" };
45
+ } catch (error) {
46
+ return { removed: false, error: (error as Error).message };
47
+ }
48
+ },
49
+ };
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import type { WorkflowHudSummary } from "../skill-state/active-state";
5
5
  import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
6
6
  import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
7
+ import type { GcPidProbe, GcRecord } from "./gc-runtime";
7
8
 
8
9
  import { applyGjcTmuxProfile, GJC_TMUX_LAUNCHED_ENV } from "./launch-tmux";
9
10
  import {
@@ -18,6 +19,7 @@ import {
18
19
  writeWorkflowEnvelopeAtomic,
19
20
  } from "./state-writer";
20
21
  import {
22
+ buildGjcTmuxExactOptionTarget,
21
23
  buildGjcTmuxUntaggedSessionHint,
22
24
  GJC_TMUX_PROFILE_OPTION,
23
25
  GJC_TMUX_PROFILE_VALUE,
@@ -677,6 +679,174 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
677
679
  throw error;
678
680
  }
679
681
  }
682
+ function isPositivePid(value: unknown): value is number {
683
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
684
+ }
685
+
686
+ function collectTeamGcWorkerPids(
687
+ heartbeat: WorkerHeartbeatFile | null,
688
+ lifecycle: GjcTeamWorkerLifecycle | null,
689
+ ): number[] {
690
+ const pids: number[] = [];
691
+ if (isPositivePid(heartbeat?.pid)) pids.push(heartbeat.pid);
692
+ if (isPositivePid(lifecycle?.pid) && !pids.includes(lifecycle.pid)) pids.push(lifecycle.pid);
693
+ return pids;
694
+ }
695
+
696
+ interface TeamGcPidClassification {
697
+ removable: boolean;
698
+ pidStatus: "dead" | "alive" | "eperm" | "unknown" | "none";
699
+ pid?: number;
700
+ }
701
+
702
+ /**
703
+ * Liveness-only, fail-closed: a worker is removable ONLY when it has at least
704
+ * one authoritative pid and EVERY candidate pid probes dead (ESRCH). Any alive,
705
+ * EPERM, or unknown candidate (heartbeat OR lifecycle) keeps the worker, so a
706
+ * dead heartbeat pid can never override a live lifecycle pid.
707
+ */
708
+ function classifyTeamGcWorkerPids(pids: number[], probe: GcPidProbe): TeamGcPidClassification {
709
+ if (pids.length === 0) return { removable: false, pidStatus: "none" };
710
+ const statuses = pids.map(pid => ({ pid, status: gcProbeStatus(probe, pid) }));
711
+ const kept = statuses.find(entry => entry.status !== "dead");
712
+ if (kept) return { removable: false, pidStatus: kept.status, pid: kept.pid };
713
+ return { removable: true, pidStatus: "dead", pid: statuses[0]?.pid };
714
+ }
715
+
716
+ function gcProbeStatus(probe: GcPidProbe, pid: number): "dead" | "alive" | "eperm" | "unknown" {
717
+ const result = probe(pid);
718
+ if (result.status === "dead") return "dead";
719
+ return result.reason ?? "unknown";
720
+ }
721
+
722
+ function teamGcRecordDetail(heartbeat: WorkerHeartbeatFile | null, lifecycle: GjcTeamWorkerLifecycle | null): string {
723
+ return [
724
+ `heartbeat=${heartbeat ? "present" : "missing"}`,
725
+ ...(heartbeat ? [`heartbeat_alive=${heartbeat.alive}`, `last_turn_at=${heartbeat.last_turn_at}`] : []),
726
+ `lifecycle=${lifecycle?.lifecycle_state ?? "missing"}`,
727
+ ...(lifecycle?.pane_id ? [`pane_id=${lifecycle.pane_id}`] : []),
728
+ ...(lifecycle?.stop_reason ? [`stop_reason=${lifecycle.stop_reason}`] : []),
729
+ ].join(" ");
730
+ }
731
+
732
+ /** @internal */
733
+ export async function listTeamWorkerGcRecords(teamRoot: string, probe: GcPidProbe): Promise<GcRecord[]> {
734
+ const teamEntries = await fs.readdir(teamRoot, { withFileTypes: true });
735
+ const records: GcRecord[] = [];
736
+ for (const teamEntry of teamEntries) {
737
+ if (!teamEntry.isDirectory()) continue;
738
+ const teamName = teamEntry.name;
739
+ const teamDirPath = path.join(teamRoot, teamName);
740
+ let workerEntries: import("node:fs").Dirent[];
741
+ try {
742
+ workerEntries = await fs.readdir(path.join(teamDirPath, "workers"), { withFileTypes: true });
743
+ } catch (error) {
744
+ if (isEnoent(error)) continue;
745
+ throw error;
746
+ }
747
+
748
+ for (const workerEntry of workerEntries) {
749
+ if (!workerEntry.isDirectory()) continue;
750
+ const workerId = workerEntry.name;
751
+ const dir = path.join(teamDirPath, "workers", workerId);
752
+ let heartbeat: WorkerHeartbeatFile | null = null;
753
+ let lifecycle: GjcTeamWorkerLifecycle | null = null;
754
+ try {
755
+ heartbeat = await readJsonFile<WorkerHeartbeatFile>(path.join(dir, "heartbeat.json"));
756
+ lifecycle = await readJsonFile<GjcTeamWorkerLifecycle>(path.join(dir, "lifecycle.json"));
757
+ } catch (error) {
758
+ records.push({
759
+ store: "team_workers",
760
+ id: `${teamName}/${workerId}`,
761
+ root: teamRoot,
762
+ path: dir,
763
+ pid_status: "none",
764
+ status: "malformed",
765
+ stale: false,
766
+ removable: false,
767
+ action: "none",
768
+ reason: "worker_state_malformed_kept",
769
+ error: error instanceof Error ? error.message : String(error),
770
+ });
771
+ continue;
772
+ }
773
+ const pids = collectTeamGcWorkerPids(heartbeat, lifecycle);
774
+ const { removable, pidStatus, pid } = classifyTeamGcWorkerPids(pids, probe);
775
+ const terminalLifecycle = lifecycle?.lifecycle_state === "failed" || lifecycle?.lifecycle_state === "stopped";
776
+ const status = removable
777
+ ? "dead"
778
+ : pidStatus === "none" && terminalLifecycle
779
+ ? "terminal_lifecycle"
780
+ : pidStatus === "none"
781
+ ? "no_pid"
782
+ : pidStatus;
783
+ records.push({
784
+ store: "team_workers",
785
+ id: `${teamName}/${workerId}`,
786
+ root: teamRoot,
787
+ path: dir,
788
+ pid,
789
+ pid_status: pidStatus,
790
+ status,
791
+ stale: removable,
792
+ removable,
793
+ action: "none",
794
+ reason: removable
795
+ ? "worker_all_pids_dead"
796
+ : pidStatus === "none" && terminalLifecycle
797
+ ? "terminal_lifecycle_without_pid_kept"
798
+ : pidStatus === "none"
799
+ ? "worker_pid_missing_kept"
800
+ : `worker_pid_${pidStatus}_kept`,
801
+ detail: teamGcRecordDetail(heartbeat, lifecycle),
802
+ });
803
+ }
804
+ }
805
+ return records;
806
+ }
807
+
808
+ /** @internal */
809
+ export async function pruneTeamWorkerGcRecord(record: GcRecord, probe: GcPidProbe): Promise<boolean> {
810
+ if (!record.path || !record.id.includes("/")) return false;
811
+ const [teamName, workerId] = record.id.split("/", 2);
812
+ if (!teamName || !workerId) return false;
813
+ const teamDirPath = path.dirname(path.dirname(record.path));
814
+ const heartbeat = await readJsonFile<WorkerHeartbeatFile>(path.join(record.path, "heartbeat.json"));
815
+ const lifecycle = await readJsonFile<GjcTeamWorkerLifecycle>(path.join(record.path, "lifecycle.json"));
816
+ const pids = collectTeamGcWorkerPids(heartbeat, lifecycle);
817
+ if (!classifyTeamGcWorkerPids(pids, probe).removable) return false;
818
+
819
+ const claimDir = path.join(teamDirPath, "claims");
820
+ try {
821
+ for (const entry of await fs.readdir(claimDir, { withFileTypes: true })) {
822
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
823
+ const claimPath = path.join(claimDir, entry.name);
824
+ const claim = readClaimRecord(await readJsonFile<unknown>(claimPath));
825
+ if (claim?.owner !== workerId) continue;
826
+ await removeFileAudited(claimPath, stateWriterOptions(claimPath, "prune", "gc-team-worker"));
827
+ }
828
+ } catch (error) {
829
+ if (!isEnoent(error)) throw error;
830
+ }
831
+
832
+ for (const task of await readTasks(teamDirPath)) {
833
+ if (task.claim?.owner !== workerId && task.assignee !== workerId) continue;
834
+ if (task.status === "completed" || task.status === "failed") continue;
835
+ await writeTask(teamDirPath, {
836
+ ...task,
837
+ status: "pending",
838
+ assignee: undefined,
839
+ claim: undefined,
840
+ version: task.version + 1,
841
+ updated_at: now(),
842
+ });
843
+ }
844
+
845
+ // Remove the stale worker record dir itself so a removable record always
846
+ // results in an observable removal, even when it owns no claims/tasks.
847
+ await fs.rm(record.path, { recursive: true, force: true });
848
+ return true;
849
+ }
680
850
  function stateCategoryForJsonPath(filePath: string): "state" | "ledger" {
681
851
  return filePath.endsWith(".jsonl") || filePath.includes(`${path.sep}telemetry${path.sep}`) ? "ledger" : "state";
682
852
  }
@@ -1633,7 +1803,7 @@ function buildTeamTmuxLeaderRequirementMessage(detail?: string): string {
1633
1803
  }
1634
1804
  function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): string {
1635
1805
  const result = Bun.spawnSync(
1636
- [tmuxCommand, "show-options", "-qv", "-t", `=${sessionName}`, GJC_TMUX_PROFILE_OPTION],
1806
+ [tmuxCommand, "show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), GJC_TMUX_PROFILE_OPTION],
1637
1807
  {
1638
1808
  stdout: "pipe",
1639
1809
  stderr: "pipe",
@@ -1645,7 +1815,14 @@ function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): stri
1645
1815
 
1646
1816
  function retagGjcLaunchedTmuxSession(tmuxCommand: string, sessionName: string): boolean {
1647
1817
  const result = Bun.spawnSync(
1648
- [tmuxCommand, "set-option", "-t", `=${sessionName}`, GJC_TMUX_PROFILE_OPTION, GJC_TMUX_PROFILE_VALUE],
1818
+ [
1819
+ tmuxCommand,
1820
+ "set-option",
1821
+ "-t",
1822
+ buildGjcTmuxExactOptionTarget(sessionName),
1823
+ GJC_TMUX_PROFILE_OPTION,
1824
+ GJC_TMUX_PROFILE_VALUE,
1825
+ ],
1649
1826
  {
1650
1827
  stdout: "pipe",
1651
1828
  stderr: "pipe",
@@ -32,6 +32,20 @@ export function resolveGjcTmuxCommand(env: NodeJS.ProcessEnv = process.env): str
32
32
  return env[GJC_TMUX_COMMAND_ENV]?.trim() || env.GJC_TEAM_TMUX_COMMAND?.trim() || "tmux";
33
33
  }
34
34
 
35
+ /**
36
+ * Build the exact-session target for tmux *option* commands
37
+ * (`show-options` / `set-option`) and `display-message -t`.
38
+ *
39
+ * Session-scoped commands such as `kill-session` / `attach-session` resolve a
40
+ * bare exact target (`=NAME`), but tmux 3.6a refuses to resolve a bare `=NAME`
41
+ * for option/display commands. Appending the empty window separator (`=NAME:`)
42
+ * keeps the exact-session match while giving tmux the window-qualified target
43
+ * those commands require. See gajae-code#580.
44
+ */
45
+ export function buildGjcTmuxExactOptionTarget(sessionName: string): string {
46
+ return `=${sessionName}:`;
47
+ }
48
+
35
49
  export const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged";
36
50
 
37
51
  export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string {