@gajae-code/coding-agent 0.4.4 → 0.5.0

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 (132) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/types/cli/fast-help.d.ts +1 -0
  3. package/dist/types/cli/setup-cli.d.ts +2 -0
  4. package/dist/types/commands/harness.d.ts +6 -0
  5. package/dist/types/commands/setup.d.ts +6 -0
  6. package/dist/types/config/model-profile-activation.d.ts +11 -2
  7. package/dist/types/config/model-profiles.d.ts +7 -0
  8. package/dist/types/config/model-registry.d.ts +6 -0
  9. package/dist/types/config/model-resolver.d.ts +2 -0
  10. package/dist/types/config/models-config-schema.d.ts +35 -0
  11. package/dist/types/config/settings-schema.d.ts +4 -3
  12. package/dist/types/coordinator/contract.d.ts +1 -1
  13. package/dist/types/coordinator-mcp/server.d.ts +8 -2
  14. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
  15. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  16. package/dist/types/harness-control-plane/finalize.d.ts +5 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +1 -1
  18. package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
  19. package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
  20. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  21. package/dist/types/harness-control-plane/receipts.d.ts +46 -0
  22. package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
  23. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  24. package/dist/types/harness-control-plane/types.d.ts +13 -1
  25. package/dist/types/hindsight/mental-models.d.ts +5 -5
  26. package/dist/types/main.d.ts +2 -2
  27. package/dist/types/modes/components/model-selector.d.ts +1 -12
  28. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  29. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  30. package/dist/types/modes/utils/abort-message.d.ts +4 -0
  31. package/dist/types/sdk.d.ts +5 -0
  32. package/dist/types/session/agent-session.d.ts +2 -0
  33. package/dist/types/session/blob-store.d.ts +20 -1
  34. package/dist/types/session/session-manager.d.ts +32 -6
  35. package/dist/types/session/streaming-output.d.ts +3 -2
  36. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  37. package/dist/types/setup/hermes-setup.d.ts +7 -0
  38. package/dist/types/task/fork-context-advisory.d.ts +13 -0
  39. package/dist/types/task/receipt.d.ts +2 -0
  40. package/dist/types/task/roi-reconciliation.d.ts +27 -0
  41. package/dist/types/task/types.d.ts +17 -0
  42. package/dist/types/thinking-metadata.d.ts +16 -0
  43. package/dist/types/thinking.d.ts +3 -12
  44. package/dist/types/tools/index.d.ts +2 -0
  45. package/dist/types/tools/resolve.d.ts +0 -10
  46. package/dist/types/utils/tool-choice.d.ts +14 -1
  47. package/package.json +8 -7
  48. package/scripts/build-binary.ts +4 -0
  49. package/src/cli/fast-help.ts +80 -0
  50. package/src/cli/setup-cli.ts +12 -3
  51. package/src/cli.ts +112 -17
  52. package/src/commands/coordinator.ts +44 -1
  53. package/src/commands/harness.ts +128 -11
  54. package/src/commands/launch.ts +2 -2
  55. package/src/commands/mcp-serve.ts +3 -2
  56. package/src/commands/session.ts +3 -1
  57. package/src/commands/setup.ts +4 -0
  58. package/src/config/model-profile-activation.ts +15 -3
  59. package/src/config/model-profiles.ts +255 -56
  60. package/src/config/model-resolver.ts +9 -6
  61. package/src/config/models-config-schema.ts +2 -0
  62. package/src/config/settings-schema.ts +6 -3
  63. package/src/coordinator/contract.ts +1 -0
  64. package/src/coordinator-mcp/server.ts +427 -193
  65. package/src/cursor.ts +46 -4
  66. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  67. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  68. package/src/export/html/index.ts +13 -9
  69. package/src/gjc-runtime/launch-worktree.ts +12 -1
  70. package/src/gjc-runtime/session-state-sidecar.ts +38 -0
  71. package/src/gjc-runtime/team-runtime.ts +33 -7
  72. package/src/gjc-runtime/tmux-common.ts +15 -0
  73. package/src/gjc-runtime/tmux-sessions.ts +19 -11
  74. package/src/gjc-runtime/ultragoal-runtime.ts +505 -41
  75. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  76. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  77. package/src/harness-control-plane/finalize.ts +39 -5
  78. package/src/harness-control-plane/owner.ts +87 -28
  79. package/src/harness-control-plane/phase-rollup.ts +96 -0
  80. package/src/harness-control-plane/receipt-ingest.ts +127 -0
  81. package/src/harness-control-plane/receipt-spool.ts +128 -0
  82. package/src/harness-control-plane/receipts.ts +229 -1
  83. package/src/harness-control-plane/rpc-adapter.ts +8 -0
  84. package/src/harness-control-plane/state-machine.ts +27 -6
  85. package/src/harness-control-plane/storage.ts +23 -0
  86. package/src/harness-control-plane/types.ts +33 -1
  87. package/src/hindsight/mental-models.ts +17 -16
  88. package/src/internal-urls/docs-index.generated.ts +8 -7
  89. package/src/main.ts +7 -3
  90. package/src/modes/components/assistant-message.ts +26 -14
  91. package/src/modes/components/diff.ts +97 -0
  92. package/src/modes/components/model-selector.ts +353 -181
  93. package/src/modes/components/status-line.ts +6 -6
  94. package/src/modes/components/tool-execution.ts +30 -13
  95. package/src/modes/controllers/event-controller.ts +5 -4
  96. package/src/modes/controllers/selector-controller.ts +33 -42
  97. package/src/modes/interactive-mode.ts +4 -5
  98. package/src/modes/print-mode.ts +1 -1
  99. package/src/modes/rpc/rpc-client.ts +3 -2
  100. package/src/modes/rpc/rpc-mode.ts +44 -14
  101. package/src/modes/rpc/rpc-types.ts +5 -2
  102. package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
  103. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  104. package/src/modes/theme/theme.ts +2 -2
  105. package/src/modes/utils/abort-message.ts +41 -0
  106. package/src/modes/utils/context-usage.ts +15 -8
  107. package/src/modes/utils/ui-helpers.ts +5 -6
  108. package/src/sdk.ts +38 -6
  109. package/src/secrets/obfuscator.ts +102 -27
  110. package/src/session/agent-session.ts +121 -25
  111. package/src/session/blob-store.ts +89 -3
  112. package/src/session/session-manager.ts +328 -57
  113. package/src/session/streaming-output.ts +185 -122
  114. package/src/session/tool-choice-queue.ts +23 -0
  115. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -2
  116. package/src/setup/hermes-setup.ts +63 -8
  117. package/src/task/executor.ts +69 -6
  118. package/src/task/fork-context-advisory.ts +99 -0
  119. package/src/task/index.ts +31 -2
  120. package/src/task/receipt.ts +7 -0
  121. package/src/task/render.ts +21 -1
  122. package/src/task/roi-reconciliation.ts +90 -0
  123. package/src/task/types.ts +15 -0
  124. package/src/thinking-metadata.ts +51 -0
  125. package/src/thinking.ts +26 -46
  126. package/src/tools/bash.ts +1 -1
  127. package/src/tools/index.ts +4 -2
  128. package/src/tools/resolve.ts +93 -18
  129. package/src/tools/subagent-render.ts +10 -1
  130. package/src/utils/edit-mode.ts +1 -1
  131. package/src/utils/title-generator.ts +16 -2
  132. package/src/utils/tool-choice.ts +45 -16
@@ -1,8 +1,13 @@
1
1
  import type { AgentToolUpdateCallback } from "@gajae-code/agent-core";
2
+
2
3
  import { sanitizeText } from "@gajae-code/utils";
3
4
  import { formatBytes } from "../tools/render-utils";
4
5
  import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
5
6
 
7
+ function sanitizeOutputChunk(rawChunk: string): string {
8
+ return sanitizeWithOptionalSixelPassthrough(rawChunk, sanitizeText);
9
+ }
10
+
6
11
  // =============================================================================
7
12
  // Constants
8
13
  // =============================================================================
@@ -12,6 +17,7 @@ export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
12
17
  export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line
13
18
 
14
19
  const NL = "\n";
20
+
15
21
  const ELLIPSIS = "…";
16
22
 
17
23
  // =============================================================================
@@ -654,10 +660,11 @@ export class OutputSink {
654
660
  #bufferBytes = 0;
655
661
  #head = "";
656
662
  #headBytes = 0;
657
- #headLines = 0; // newline count inside #head
658
663
  #headRetentionDisabled = false;
659
664
  #totalLines = 0; // newline count
660
665
  #totalBytes = 0;
666
+ #processedBytes = 0;
667
+ #processedLines = 0; // newline count after sanitize/column-cap
661
668
  #sawData = false;
662
669
  #truncated = false;
663
670
  #lastChunkTime = 0;
@@ -674,8 +681,12 @@ export class OutputSink {
674
681
  sink: Bun.FileSink;
675
682
  };
676
683
 
677
- // Queue of chunks waiting for the file sink to be created.
684
+ // Raw prefix chunks not yet confirmed written to the file sink. This queue is
685
+ // the only artifact replay source; retained head/tail windows are lossy views.
678
686
  #pendingFileWrites?: string[];
687
+ #pendingFileWriteBytes = 0;
688
+ #finalized = false;
689
+
679
690
  #fileReady = false;
680
691
 
681
692
  readonly #artifactPath?: string;
@@ -708,76 +719,99 @@ export class OutputSink {
708
719
  this.#chunkThrottleMs = chunkThrottleMs;
709
720
  }
710
721
 
722
+ #headText(): string {
723
+ return this.#head;
724
+ }
725
+
726
+ #tailText(): string {
727
+ return this.#buffer;
728
+ }
729
+
730
+ #setTail(text: string, bytes = Buffer.byteLength(text, "utf-8")): void {
731
+ this.#buffer = text;
732
+ this.#bufferBytes = bytes;
733
+ }
734
+
735
+ #appendTail(text: string, bytes: number): void {
736
+ this.#buffer += text;
737
+ this.#bufferBytes += bytes;
738
+ }
739
+
740
+ #appendHead(text: string, bytes: number): void {
741
+ this.#head += text;
742
+ this.#headBytes += bytes;
743
+ }
744
+
745
+ #trimTailTo(maxBytes: number): void {
746
+ if (this.#bufferBytes <= maxBytes) return;
747
+ const { text, bytes } = truncateTailBytes(this.#buffer, maxBytes);
748
+ this.#buffer = text;
749
+ this.#bufferBytes = bytes;
750
+ }
751
+
711
752
  /**
712
- * Push a chunk of output. The buffer management and onChunk callback run
713
- * synchronously. File sink writes are deferred and serialized internally.
753
+ * Push a chunk of output. Raw bytes are mirrored to artifacts, while the
754
+ * visible retention windows are selected from the sanitized/column-capped
755
+ * stream so production-default display matches the historical processed view.
714
756
  */
715
757
  push(chunk: string): void {
716
- chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
758
+ const rawChunk = chunk;
717
759
 
718
- // Unthrottled raw-chunk hook fires before any throttle/cap gating so
719
- // downstream consumers (e.g. AsyncJobManager.appendOutput) can record
720
- // the complete process stream while UI/progress callbacks remain throttled.
721
- if (this.#onRawChunk && chunk.length > 0) {
722
- this.#onRawChunk(chunk);
760
+ // Live callbacks historically observe sanitized, uncapped chunks. The same
761
+ // sanitized text is also the input to visible accounting/retention.
762
+ const sanitizedChunk = sanitizeOutputChunk(rawChunk);
763
+
764
+ if (this.#onRawChunk && sanitizedChunk.length > 0) {
765
+ this.#onRawChunk(sanitizedChunk);
723
766
  }
724
767
 
725
- // Throttled onChunk: only call the callback when enough time has passed.
726
- // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
727
- // what reached the sink — the column cap is for the persisted LLM view.
728
768
  if (this.#onChunk) {
729
769
  const now = Date.now();
730
770
  if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
731
771
  this.#lastChunkTime = now;
732
- this.#onChunk(chunk);
772
+ this.#onChunk(sanitizedChunk);
733
773
  }
734
774
  }
735
775
 
736
- const rawBytes = Buffer.byteLength(chunk, "utf-8");
776
+ const rawBytes = Buffer.byteLength(rawChunk, "utf-8");
737
777
  this.#totalBytes += rawBytes;
738
778
 
739
- if (chunk.length > 0) {
779
+ if (rawChunk.length > 0) {
740
780
  this.#sawData = true;
741
- this.#totalLines += countNewlines(chunk);
781
+ this.#totalLines += countNewlines(rawChunk);
742
782
  }
743
783
 
744
- // Per-line column cap. State persists across chunks so a mid-line split
745
- // still respects the budget. Operates on the sanitized chunk; the cap is
746
- // applied before head/tail accounting but after artifact mirroring decides.
747
- const capped = this.#maxColumns > 0 ? this.#applyColumnCap(chunk) : chunk;
748
- const cappedBytes = capped === chunk ? rawBytes : Buffer.byteLength(capped, "utf-8");
749
- const cappedThisChunk = cappedBytes < rawBytes;
750
- if (cappedThisChunk) this.#truncated = true;
751
-
752
- // Mirror RAW chunk to the artifact file so the on-disk record is the full
753
- // uncapped stream. Mirror triggers on: in-memory overflow OR this chunk's
754
- // column cap dropped bytes (otherwise we'd lose data) OR file already open.
755
- if (this.#artifactPath && (this.#file != null || cappedThisChunk || this.#willOverflow(cappedBytes))) {
756
- this.#writeToFile(chunk);
784
+ // Mirror the original, unsanitized/uncapped bytes. Until the artifact sink is
785
+ // open, keep an independent raw replay prefix because retained head/tail
786
+ // windows are trimmed and cannot reconstruct byte-correct artifacts.
787
+ if (this.#artifactPath && this.#maxColumns === 0) this.#enqueueFileWrite(rawChunk, rawBytes);
788
+
789
+ if (rawBytes === 0) return;
790
+
791
+ const visibleChunk = this.#maxColumns > 0 ? this.#applyColumnCap(sanitizedChunk) : sanitizedChunk;
792
+ if (this.#artifactPath && this.#maxColumns > 0) this.#enqueueFileWrite(rawChunk, rawBytes);
793
+ if (this.#columnDroppedBytes > 0) this.#createFileSink();
794
+ const visibleBytes = Buffer.byteLength(visibleChunk, "utf-8");
795
+ if (visibleChunk.length > 0) {
796
+ this.#processedBytes += visibleBytes;
797
+ this.#processedLines += countNewlines(visibleChunk);
757
798
  }
799
+ if (visibleBytes === 0) return;
758
800
 
759
- if (cappedBytes === 0) return;
801
+ let tailChunk = visibleChunk;
802
+ let tailBytes = visibleBytes;
760
803
 
761
- // Head retention: drain the (capped) chunk into #head until the budget is
762
- // exhausted, then forward any leftover to the tail buffer.
763
- let tailChunk = capped;
764
- let tailBytes = cappedBytes;
765
804
  if (this.#headLimit > 0 && !this.#headRetentionDisabled && this.#headBytes < this.#headLimit) {
766
805
  const room = this.#headLimit - this.#headBytes;
767
- if (cappedBytes <= room) {
768
- this.#head += capped;
769
- this.#headBytes += cappedBytes;
770
- this.#headLines += countNewlines(capped);
806
+ if (visibleBytes <= room) {
807
+ this.#appendHead(visibleChunk, visibleBytes);
771
808
  return;
772
809
  }
773
- // Split: head takes a UTF-8-safe prefix; remainder flows to tail.
774
- const headSlice = truncateHeadBytes(capped, room);
810
+ const headSlice = truncateHeadBytes(visibleChunk, room);
775
811
  if (headSlice.bytes > 0) {
776
- this.#head += headSlice.text;
777
- this.#headBytes += headSlice.bytes;
778
- this.#headLines += countNewlines(headSlice.text);
779
- tailChunk = capped.substring(headSlice.text.length);
780
- tailBytes = cappedBytes - headSlice.bytes;
812
+ this.#appendHead(headSlice.text, headSlice.bytes);
813
+ tailChunk = visibleChunk.substring(headSlice.text.length);
814
+ tailBytes = visibleBytes - headSlice.bytes;
781
815
  }
782
816
  }
783
817
 
@@ -790,6 +824,7 @@ export class OutputSink {
790
824
  * cap; subsequent bytes are skipped until the next `\n`. State persists
791
825
  * across calls so a long line split across chunks still produces one marker.
792
826
  */
827
+
793
828
  #applyColumnCap(chunk: string): string {
794
829
  if (chunk.length === 0) return chunk;
795
830
  const max = this.#maxColumns;
@@ -800,11 +835,11 @@ export class OutputSink {
800
835
  const segEnd = nlIdx === -1 ? chunk.length : nlIdx;
801
836
  if (segEnd > cursor) {
802
837
  const segment = chunk.substring(cursor, segEnd);
838
+ const segBytes = Buffer.byteLength(segment, "utf-8");
803
839
  if (this.#columnEllipsisAdded) {
804
840
  // Past the cap; drop until newline.
805
- this.#columnDroppedBytes += Buffer.byteLength(segment, "utf-8");
841
+ this.#columnDroppedBytes += segBytes;
806
842
  } else {
807
- const segBytes = Buffer.byteLength(segment, "utf-8");
808
843
  const remaining = max - this.#currentLineBytes;
809
844
  if (segBytes <= remaining) {
810
845
  parts.push(segment);
@@ -814,13 +849,11 @@ export class OutputSink {
814
849
  // arm the skip-until-newline flag.
815
850
  const ellipsisBytes = 3; // "…" in UTF-8
816
851
  const headRoom = Math.max(0, remaining - ellipsisBytes);
817
- let kept = "";
818
852
  let keptBytes = 0;
819
853
  if (headRoom > 0) {
820
854
  const sliced = truncateHeadBytes(segment, headRoom);
821
- kept = sliced.text;
822
855
  keptBytes = sliced.bytes;
823
- parts.push(kept);
856
+ parts.push(sliced.text);
824
857
  }
825
858
  parts.push(ELLIPSIS);
826
859
  this.#columnDroppedBytes += segBytes - keptBytes;
@@ -839,6 +872,10 @@ export class OutputSink {
839
872
  return parts.join("");
840
873
  }
841
874
 
875
+ #retainedSummary(text: string): { text: string; bytes: number; lines: number } {
876
+ return { text, bytes: Buffer.byteLength(text, "utf-8"), lines: text.length > 0 ? countNewlines(text) : 0 };
877
+ }
878
+
842
879
  #willOverflow(dataBytes: number): boolean {
843
880
  // Triggers file mirroring as soon as the next chunk would push us over
844
881
  // the tail budget (head retention does not change spill-to-artifact).
@@ -852,8 +889,7 @@ export class OutputSink {
852
889
  const willOverflow = this.#bufferBytes + dataBytes > threshold;
853
890
 
854
891
  if (!willOverflow) {
855
- this.#buffer += chunk;
856
- this.#bufferBytes += dataBytes;
892
+ this.#appendTail(chunk, dataBytes);
857
893
  return;
858
894
  }
859
895
 
@@ -863,66 +899,75 @@ export class OutputSink {
863
899
  // Avoid creating a giant intermediate string when chunk alone dominates.
864
900
  if (dataBytes >= threshold) {
865
901
  const { text, bytes } = truncateTailBytes(chunk, threshold);
866
- this.#buffer = text;
867
- this.#bufferBytes = bytes;
902
+ this.#setTail(text, bytes);
868
903
  } else {
869
904
  // Intermediate size is bounded (<= threshold + dataBytes), safe to concat.
870
- this.#buffer += chunk;
871
- this.#bufferBytes += dataBytes;
872
-
873
- const { text, bytes } = truncateTailBytes(this.#buffer, threshold);
874
- this.#buffer = text;
875
- this.#bufferBytes = bytes;
905
+ this.#appendTail(chunk, dataBytes);
906
+ this.#trimTailTo(threshold);
876
907
  }
877
908
  }
878
909
 
879
- /**
880
- * Write a chunk to the artifact file. Handles the async file sink creation
881
- * by queuing writes until the sink is ready, then draining synchronously.
882
- */
883
- #writeToFile(chunk: string): void {
884
- if (this.#fileReady && this.#file) {
885
- // Fast path: file sink exists, write synchronously
886
- this.#file.sink.write(chunk);
910
+ #queuePendingFileWrite(chunk: string, bytes = Buffer.byteLength(chunk, "utf-8")): void {
911
+ if (!this.#pendingFileWrites) this.#pendingFileWrites = [chunk];
912
+ else this.#pendingFileWrites.push(chunk);
913
+
914
+ this.#pendingFileWriteBytes += bytes;
915
+ }
916
+
917
+ #enqueueFileWrite(chunk: string, bytes: number): void {
918
+ if (!this.#fileReady || !this.#file) {
919
+ this.#queuePendingFileWrite(chunk, bytes);
920
+ if (this.#willOverflow(bytes) || this.#pendingFileWriteBytes > this.#spillThreshold) this.#createFileSink();
887
921
  return;
888
922
  }
889
- // File sink not yet created — queue this chunk and kick off creation
890
- if (!this.#pendingFileWrites) {
891
- this.#pendingFileWrites = [chunk];
892
- void this.#createFileSink();
893
- } else {
894
- this.#pendingFileWrites.push(chunk);
923
+
924
+ try {
925
+ this.#file.sink.write(chunk);
926
+ } catch {
927
+ try {
928
+ void this.#file.sink.end();
929
+ } catch {
930
+ /* ignore */
931
+ }
932
+ this.#file = undefined;
933
+ this.#fileReady = false;
934
+ this.#queuePendingFileWrite(chunk, bytes);
935
+ this.#createFileSink();
895
936
  }
896
937
  }
897
938
 
898
- async #createFileSink(): Promise<void> {
899
- if (!this.#artifactPath || this.#fileReady) return;
939
+ #createFileSink(): boolean {
940
+ if (this.#finalized) return false;
941
+
942
+ if (!this.#artifactPath) return false;
943
+ if (this.#fileReady) return this.#file != null;
900
944
  try {
901
945
  const sink = Bun.file(this.#artifactPath).writer();
902
946
  this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
903
947
 
904
- // Flush existing buffer to file BEFORE it gets trimmed further.
905
- if (this.#buffer.length > 0) {
906
- sink.write(this.#buffer);
907
- }
908
-
909
- // Drain any chunks that arrived while the sink was being created
910
- if (this.#pendingFileWrites) {
911
- for (const pending of this.#pendingFileWrites) {
912
- sink.write(pending);
913
- }
914
- this.#pendingFileWrites = undefined;
948
+ const pending = this.#pendingFileWrites;
949
+ if (pending) {
950
+ for (const chunk of pending) sink.write(chunk);
915
951
  }
916
952
 
917
953
  this.#fileReady = true;
954
+ this.#pendingFileWrites = undefined;
955
+ this.#pendingFileWriteBytes = 0;
956
+
957
+ return true;
918
958
  } catch {
919
959
  try {
920
- await this.#file?.sink?.end();
960
+ void this.#file?.sink?.end();
921
961
  } catch {
922
962
  /* ignore */
923
963
  }
924
964
  this.#file = undefined;
925
- this.#pendingFileWrites = undefined;
965
+ // Keep #pendingFileWriteBytes in sync with the preserved queue so
966
+ // later retry/threshold decisions don't undercount retained bytes.
967
+ this.#pendingFileWriteBytes = this.#pendingFileWrites
968
+ ? this.#pendingFileWrites.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0)
969
+ : 0;
970
+ return false;
926
971
  }
927
972
  }
928
973
 
@@ -953,14 +998,14 @@ export class OutputSink {
953
998
  * branch in `dump()` against stale totals.
954
999
  */
955
1000
  replace(text: string): void {
956
- this.#buffer = text;
957
- this.#bufferBytes = Buffer.byteLength(text, "utf-8");
1001
+ this.#setTail(text);
958
1002
  this.#head = "";
959
1003
  this.#headBytes = 0;
960
- this.#headLines = 0;
961
1004
  this.#headRetentionDisabled = true;
962
1005
  this.#totalBytes = this.#bufferBytes;
1006
+ this.#processedBytes = this.#bufferBytes;
963
1007
  this.#totalLines = countNewlines(text);
1008
+ this.#processedLines = this.#totalLines;
964
1009
  this.#sawData = text.length > 0;
965
1010
  this.#truncated = false;
966
1011
  this.#currentLineBytes = 0;
@@ -973,19 +1018,34 @@ export class OutputSink {
973
1018
  const noticeLine = notice ? `[${notice}]\n` : "";
974
1019
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
975
1020
 
976
- if (this.#file) await this.#file.sink.end();
977
-
978
- // Compose the visible output. With head retention, splice head + marker
979
- // + tail when content was elided. Otherwise return the rolling buffer.
980
- const headBytes = this.#headBytes;
981
- const tailBuf = this.#buffer;
982
- const tailBytes = this.#bufferBytes;
983
- const headLines = this.#headLines + (headBytes > 0 && !this.#head.endsWith("\n") ? 1 : 0);
984
- const tailLines = tailBuf.length > 0 ? countNewlines(tailBuf) + 1 : 0;
985
-
986
- // Bytes that survived the column cap. Middle elision operates on these,
987
- // so column-dropped bytes don't inflate the "elided from middle" count.
988
- const effectiveTotalBytes = Math.max(0, this.#totalBytes - this.#columnDroppedBytes);
1021
+ let artifactId: string | undefined;
1022
+ if (this.#file) {
1023
+ artifactId = this.#file.artifactId;
1024
+ await this.#file.sink.end();
1025
+ this.#finalized = true;
1026
+ }
1027
+ if (this.#finalized) {
1028
+ // Terminal: the artifact is closed; replay state is no longer needed.
1029
+ this.#pendingFileWrites = undefined;
1030
+ this.#pendingFileWriteBytes = 0;
1031
+ this.#fileReady = false;
1032
+ }
1033
+ // Non-finalized dumps (no artifact sink ever opened) keep the raw replay
1034
+ // queue so a later post-dump push that spills produces a CUMULATIVE
1035
+ // artifact, matching the cumulative visible summary/counters.
1036
+
1037
+ // Compose the visible output from already-processed retention windows.
1038
+ const processedTotalLines = this.#sawData ? this.#processedLines + 1 : 0;
1039
+ const headText = this.#headText();
1040
+ const head = this.#retainedSummary(headText);
1041
+
1042
+ const headBytes = head.bytes;
1043
+ const headLines = head.lines + (headBytes > 0 && !headText.endsWith("\n") ? 1 : 0);
1044
+ const tailBuf = this.#tailText();
1045
+ const tail = this.#retainedSummary(tailBuf);
1046
+ const tailBytes = tail.bytes;
1047
+ const tailLines = tailBuf.length > 0 ? tail.lines + 1 : 0;
1048
+ const effectiveTotalBytes = this.#processedBytes;
989
1049
 
990
1050
  let body: string;
991
1051
  let outputBytes: number;
@@ -996,23 +1056,25 @@ export class OutputSink {
996
1056
  if (headBytes > 0 && effectiveTotalBytes > headBytes + tailBytes) {
997
1057
  // Middle was elided. Emit head + marker + tail.
998
1058
  elidedBytes = Math.max(0, effectiveTotalBytes - headBytes - tailBytes);
999
- elidedLines = Math.max(0, totalLines - headLines - tailLines);
1000
- const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
1001
- const markerBytes = Buffer.byteLength(marker, "utf-8");
1002
- const headSep = this.#head.endsWith("\n") ? "" : "\n";
1003
- const tailSep = tailBuf.startsWith("\n") ? "" : "\n";
1004
- body = `${this.#head}${headSep}${marker}${tailSep}${tailBuf}`;
1005
- outputBytes =
1006
- headBytes +
1007
- markerBytes +
1008
- tailBytes +
1009
- Buffer.byteLength(headSep, "utf-8") +
1010
- Buffer.byteLength(tailSep, "utf-8");
1011
- outputLines = headLines + 1 + tailLines;
1012
- this.#truncated = true;
1059
+ elidedLines = Math.max(0, processedTotalLines - headLines - tailLines);
1060
+ if (elidedLines === 0) {
1061
+ body = headText;
1062
+ outputBytes = headBytes;
1063
+ outputLines = headLines;
1064
+ this.#truncated = true;
1065
+ } else {
1066
+ const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
1067
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
1068
+ const headSep = headText.endsWith("\n") ? "" : "\n";
1069
+ const tailSep = tailBuf.startsWith("\n") ? "" : "\n";
1070
+ body = `${headText}${headSep}${marker}${tailSep}${tailBuf}`;
1071
+ outputBytes = headBytes + markerBytes + tailBytes + headSep.length + tailSep.length;
1072
+ outputLines = headLines + 1 + tailLines;
1073
+ this.#truncated = true;
1074
+ }
1013
1075
  } else if (headBytes > 0) {
1014
1076
  // Head + tail combine into the full buffered output (no overlap or elision).
1015
- body = `${this.#head}${tailBuf}`;
1077
+ body = `${headText}${tailBuf}`;
1016
1078
  outputBytes = headBytes + tailBytes;
1017
1079
  outputLines = body.length > 0 ? countNewlines(body) + 1 : 0;
1018
1080
  } else {
@@ -1021,6 +1083,7 @@ export class OutputSink {
1021
1083
  outputLines = tailLines;
1022
1084
  }
1023
1085
 
1086
+ if (this.#columnDroppedBytes > 0) this.#truncated = true;
1024
1087
  return {
1025
1088
  output: `${noticeLine}${body}`,
1026
1089
  truncated: this.#truncated,
@@ -1032,7 +1095,7 @@ export class OutputSink {
1032
1095
  elidedLines,
1033
1096
  columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
1034
1097
  columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
1035
- artifactId: this.#file?.artifactId,
1098
+ artifactId,
1036
1099
  };
1037
1100
  }
1038
1101
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolChoice } from "@gajae-code/ai";
2
+ import { logger } from "@gajae-code/utils";
2
3
 
3
4
  // ── Callback types ──────────────────────────────────────────────────────────
4
5
 
@@ -168,6 +169,28 @@ export class ToolChoiceQueue {
168
169
  }
169
170
  }
170
171
 
172
+ /**
173
+ * Drop an in-flight yield after runtime tool_choice degradation. This bypasses
174
+ * onRejected so forced directives cannot requeue themselves after the model
175
+ * proved incapable of honoring the forced choice.
176
+ */
177
+ degradeInFlight(reason?: string): string | undefined {
178
+ const inFlight = this.#inFlight;
179
+ this.#inFlight = undefined;
180
+ if (!inFlight) return undefined;
181
+
182
+ const label = inFlight.directive.label;
183
+ if (this.#queue[0] === inFlight.directive) {
184
+ this.#queue.shift();
185
+ }
186
+
187
+ logger.debug("ToolChoiceQueue: dropped in-flight directive after tool_choice degradation", {
188
+ label,
189
+ reason,
190
+ });
191
+ return label;
192
+ }
193
+
171
194
  /** True if there is an in-flight yield that hasn't been resolved or rejected. */
172
195
  get hasInFlight(): boolean {
173
196
  return this.#inFlight !== undefined;
@@ -10,15 +10,16 @@ These instructions teach a Hermes-style coordinator how to operate GJC through t
10
10
  2. Send exactly one bounded task prompt with `{{TOOL_PREFIX}}_send_prompt`.
11
11
  3. Store the returned `turn_id`.
12
12
  4. Poll `{{TOOL_PREFIX}}_read_turn` or `{{TOOL_PREFIX}}_await_turn` for that `turn_id` until the turn is terminal.
13
+ If a second task is needed while one turn is active, pass `queue: true`; the next queued turn is promoted after the active turn is reported terminal.
13
14
  5. If GJC asks a structured question, use `{{TOOL_PREFIX}}_list_questions` and answer with `{{TOOL_PREFIX}}_submit_question_answer`.
14
15
  6. Use `{{TOOL_PREFIX}}_report_status` for coordinator-visible status and final reports.
15
16
  7. Use `{{TOOL_PREFIX}}_read_tail` only as advisory debug output when structured turn state is insufficient.
16
17
 
17
18
  Do not report completion to the user until the GJC turn is terminal. Do not infer completion from terminal scrollback alone.
18
19
 
19
- ## Model and provider policy
20
+ ## Worktree, model, and provider policy
20
21
 
21
- The Hermes bridge does not choose a model/provider. When no session command is configured, GJC uses its normal local model/provider resolution. If the operator config supplies `GJC_COORDINATOR_MCP_SESSION_COMMAND`, preserve it as explicit user intent.
22
+ The Hermes bridge does not choose a model/provider. Generated setup configures `GJC_COORDINATOR_MCP_SESSION_COMMAND` to `gjc --worktree` by default, so GJC creates and tracks the worktree while still using normal local model/provider resolution. Keep worktree creation inside GJC rather than creating unmanaged Hermes-side git worktrees; this preserves the original project identity for session listing and resume. If the operator config supplies a different `GJC_COORDINATOR_MCP_SESSION_COMMAND`, preserve it as explicit user intent.
22
23
 
23
24
  Provider-specific commands are examples only, never product defaults.
24
25
 
@@ -24,6 +24,8 @@ export interface HermesSetupFlags {
24
24
  repo?: string;
25
25
  profile?: string;
26
26
  sessionCommand?: string;
27
+ noWorktree?: boolean;
28
+ worktreeName?: string;
27
29
  stateRoot?: string;
28
30
  mutation?: string[];
29
31
  artifactByteCap?: string;
@@ -47,6 +49,11 @@ export interface CoordinatorSetupSpec {
47
49
  repo?: string;
48
50
  };
49
51
  sessionCommand?: string;
52
+ sessionCommandSource: "default" | "explicit";
53
+ worktree: {
54
+ enabled: boolean;
55
+ name?: string;
56
+ };
50
57
  stateRoot?: string;
51
58
  mutationPolicy: {
52
59
  classes: HermesMutationClass[];
@@ -157,6 +164,39 @@ function parseByteCap(value: string | undefined): number | undefined {
157
164
  return parsed;
158
165
  }
159
166
 
167
+ function normalizeWorktreeName(value: string | undefined): string | undefined {
168
+ const trimmed = optionalTrim(value);
169
+ if (!trimmed) return undefined;
170
+ if (trimmed.startsWith("-") || !/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,127}$/.test(trimmed)) {
171
+ throw new HermesSetupError(`Invalid Hermes worktree name: ${trimmed}`, 2);
172
+ }
173
+ return trimmed;
174
+ }
175
+
176
+ function resolveHermesWorktree(flags: HermesSetupFlags): CoordinatorSetupSpec["worktree"] {
177
+ if (flags.noWorktree && flags.worktreeName) {
178
+ throw new HermesSetupError("Use either --no-worktree or --worktree-name, not both.", 2);
179
+ }
180
+ const name = normalizeWorktreeName(flags.worktreeName);
181
+ return flags.noWorktree ? { enabled: false } : { enabled: true, ...(name ? { name } : {}) };
182
+ }
183
+
184
+ function resolveHermesSessionCommand(gjcCommand: string, flags: HermesSetupFlags): string {
185
+ const explicit = optionalTrim(flags.sessionCommand);
186
+ if (explicit) {
187
+ if (flags.noWorktree || flags.worktreeName) {
188
+ throw new HermesSetupError(
189
+ "Use either --session-command or Hermes worktree flags; explicit session commands are preserved exactly.",
190
+ 2,
191
+ );
192
+ }
193
+ return explicit;
194
+ }
195
+ const worktree = resolveHermesWorktree(flags);
196
+ if (!worktree.enabled) return gjcCommand;
197
+ return worktree.name ? `${gjcCommand} --worktree ${worktree.name}` : `${gjcCommand} --worktree`;
198
+ }
199
+
160
200
  function normalizeInstallTarget(flags: HermesSetupFlags): CoordinatorSetupSpec["installTarget"] {
161
201
  if (flags.target && flags.profileDir) {
162
202
  throw new HermesSetupError("Use exactly one of --target or --profile-dir for Hermes setup install targets.", 2);
@@ -169,20 +209,24 @@ function normalizeInstallTarget(flags: HermesSetupFlags): CoordinatorSetupSpec["
169
209
 
170
210
  export function buildHermesSetupSpec(flags: HermesSetupFlags): CoordinatorSetupSpec {
171
211
  const roots = normalizeRoots(flags.root);
212
+ const gjcCommand = optionalTrim(flags.gjcCommand) ?? DEFAULT_GJC_COMMAND;
213
+ const sessionCommand = resolveHermesSessionCommand(gjcCommand, flags);
172
214
  return {
173
215
  schemaVersion: 1,
174
216
  coordinator: "hermes",
175
217
  serverKey: optionalTrim(flags.serverKey) ?? DEFAULT_SERVER_KEY,
176
218
  serverName: COORDINATOR_MCP_SERVER_NAME,
177
219
  protocolVersion: COORDINATOR_MCP_PROTOCOL_VERSION,
178
- gjcCommand: optionalTrim(flags.gjcCommand) ?? DEFAULT_GJC_COMMAND,
220
+ gjcCommand,
179
221
  args: ["mcp-serve", "coordinator"],
180
222
  roots,
181
223
  namespace: {
182
224
  ...(optionalTrim(flags.profile) ? { profile: optionalTrim(flags.profile) } : {}),
183
225
  ...(optionalTrim(flags.repo) ? { repo: optionalTrim(flags.repo) } : {}),
184
226
  },
185
- ...(optionalTrim(flags.sessionCommand) ? { sessionCommand: flags.sessionCommand } : {}),
227
+ worktree: resolveHermesWorktree(flags),
228
+ sessionCommandSource: optionalTrim(flags.sessionCommand) ? "explicit" : "default",
229
+ sessionCommand,
186
230
  ...(optionalTrim(flags.stateRoot) ? { stateRoot: path.resolve(flags.stateRoot!) } : {}),
187
231
  mutationPolicy: {
188
232
  classes: parseMutationClasses(flags.mutation),
@@ -214,6 +258,8 @@ function signaturePayload(spec: CoordinatorSetupSpec): Record<string, unknown> {
214
258
  contractDocVersion: spec.contractDocVersion,
215
259
  coordinator: spec.coordinator,
216
260
  mutationClasses: spec.mutationPolicy.classes,
261
+ worktree: spec.worktree,
262
+ sessionCommandSource: spec.sessionCommandSource,
217
263
  namespace: spec.namespace,
218
264
  operatorTemplateVersion: spec.operatorTemplateVersion,
219
265
  roots: spec.roots,
@@ -360,7 +406,9 @@ async function runSmoke(spec: CoordinatorSetupSpec): Promise<HermesSetupResult["
360
406
  const requiredTools = [...COORDINATOR_MCP_TOOL_NAMES];
361
407
  const server = createCoordinatorMcpServer({ env: {} });
362
408
  const listed = await server.handleJsonRpc({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} });
363
- const advertised = new Set((listed.result?.tools ?? []).map((tool: { name: string }) => tool.name));
409
+ const listedResult = isRecord(listed.result) ? listed.result : {};
410
+ const tools = Array.isArray(listedResult.tools) ? listedResult.tools : [];
411
+ const advertised = new Set(tools.map(tool => (isRecord(tool) ? String(tool.name) : "")));
364
412
  const missingTools = requiredTools.filter(tool => !advertised.has(tool));
365
413
  return {
366
414
  ok: missingTools.length === 0,
@@ -398,11 +446,18 @@ export async function runHermesSetup(flags: HermesSetupFlags): Promise<HermesSet
398
446
  mode,
399
447
  files_written,
400
448
  previews,
401
- warnings: spec.sessionCommand
402
- ? [
403
- "Using explicit GJC_COORDINATOR_MCP_SESSION_COMMAND exactly as supplied; provider/model validation is not performed.",
404
- ]
405
- : ["No session command supplied; spawned sessions use the default GJC command/model resolution."],
449
+ warnings:
450
+ spec.sessionCommandSource === "explicit"
451
+ ? [
452
+ "Using explicit GJC_COORDINATOR_MCP_SESSION_COMMAND exactly as supplied; provider/model/worktree validation is not performed.",
453
+ ]
454
+ : spec.worktree.enabled
455
+ ? [
456
+ `GJC_COORDINATOR_MCP_SESSION_COMMAND defaults to '${spec.sessionCommand}' so GJC owns worktree creation and resume identity.`,
457
+ ]
458
+ : [
459
+ "GJC_COORDINATOR_MCP_SESSION_COMMAND defaults to the configured gjc command with worktree isolation disabled by user request.",
460
+ ],
406
461
  smoke,
407
462
  };
408
463
  }