@gajae-code/coding-agent 0.11.11 → 0.12.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 (182) hide show
  1. package/CHANGELOG.md +49 -3
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/commands/deep-interview.d.ts +9 -0
  4. package/dist/types/commands/harness.d.ts +22 -0
  5. package/dist/types/commands/read.d.ts +6 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/provider-auth-health.d.ts +14 -0
  8. package/dist/types/config/provider-ranking.d.ts +59 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +22 -2
  12. package/dist/types/extensibility/shared-events.d.ts +6 -0
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  14. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  15. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  16. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  17. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  19. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +52 -1
  20. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  21. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  23. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  24. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  26. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  27. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  28. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  29. package/dist/types/main.d.ts +1 -1
  30. package/dist/types/memories/index.d.ts +2 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +8 -0
  32. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  33. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  35. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  36. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  38. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  39. package/dist/types/modes/interactive-mode.d.ts +2 -0
  40. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  41. package/dist/types/modes/types.d.ts +2 -0
  42. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  43. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  44. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  45. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  46. package/dist/types/sdk/bus/index.d.ts +15 -1
  47. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  49. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  50. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  51. package/dist/types/sdk/host/host.d.ts +2 -0
  52. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  53. package/dist/types/session/agent-session.d.ts +44 -10
  54. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  55. package/dist/types/session/session-manager.d.ts +25 -13
  56. package/dist/types/session/streaming-output.d.ts +60 -5
  57. package/dist/types/skill-state/active-state.d.ts +0 -4
  58. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  59. package/dist/types/tools/browser/launch.d.ts +7 -0
  60. package/dist/types/tools/fetch.d.ts +17 -1
  61. package/dist/types/tools/output-meta.d.ts +21 -1
  62. package/dist/types/tools/read-internals.d.ts +12 -0
  63. package/dist/types/tools/read.d.ts +45 -1
  64. package/dist/types/tools/resource-gc.d.ts +4 -0
  65. package/dist/types/tools/tool-result.d.ts +7 -1
  66. package/package.json +9 -13
  67. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  68. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  69. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  71. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  72. package/src/capability/index.ts +8 -0
  73. package/src/cli/read-cli.ts +2 -1
  74. package/src/cli.ts +1 -0
  75. package/src/commands/deep-interview.ts +29 -9
  76. package/src/commands/harness.ts +35 -12
  77. package/src/commands/read.ts +11 -2
  78. package/src/commands/team.ts +1 -1
  79. package/src/config/model-registry.ts +21 -2
  80. package/src/config/provider-auth-health.ts +42 -0
  81. package/src/config/provider-ranking.ts +119 -0
  82. package/src/config/settings-schema.ts +23 -1
  83. package/src/config/settings.ts +246 -108
  84. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  85. package/src/defaults/gjc/skills/ralplan/SKILL.md +58 -25
  86. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  87. package/src/defaults/gjc/skills/ultragoal/SKILL.md +25 -15
  88. package/src/eval/py/executor.ts +7 -2
  89. package/src/extensibility/extensions/types.ts +28 -4
  90. package/src/extensibility/shared-events.ts +5 -0
  91. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  92. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  93. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  94. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  95. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  96. package/src/gjc-runtime/gc-render.ts +1 -0
  97. package/src/gjc-runtime/gc-runtime.ts +11 -1
  98. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  99. package/src/gjc-runtime/ralplan-runtime.ts +742 -85
  100. package/src/gjc-runtime/state-runtime.ts +2 -1
  101. package/src/gjc-runtime/state-writer.ts +70 -222
  102. package/src/gjc-runtime/team-launch.ts +34 -5
  103. package/src/gjc-runtime/team-runtime.ts +970 -33
  104. package/src/gjc-runtime/team-store.ts +32 -0
  105. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  106. package/src/gjc-runtime/team-workers.ts +1 -0
  107. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  108. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  109. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  110. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  111. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  112. package/src/hooks/native-skill-hook.ts +17 -4
  113. package/src/hooks/skill-state.ts +1 -23
  114. package/src/internal-urls/docs-index.generated.ts +8 -9
  115. package/src/internal-urls/local-root-gc.ts +127 -0
  116. package/src/main.ts +9 -5
  117. package/src/memories/index.ts +10 -0
  118. package/src/modes/DESIGN.md +47 -0
  119. package/src/modes/acp/acp-agent.ts +43 -7
  120. package/src/modes/acp/acp-event-mapper.ts +14 -7
  121. package/src/modes/acp/acp-mode.ts +39 -3
  122. package/src/modes/components/assistant-message.ts +123 -22
  123. package/src/modes/components/irc-sidebar.ts +176 -62
  124. package/src/modes/components/model-selector.ts +61 -4
  125. package/src/modes/components/oauth-selector.ts +42 -12
  126. package/src/modes/components/read-tool-group.ts +82 -14
  127. package/src/modes/components/session-observer-overlay.ts +187 -32
  128. package/src/modes/components/settings-selector.ts +60 -18
  129. package/src/modes/components/tool-execution.ts +138 -27
  130. package/src/modes/controllers/event-controller.ts +109 -27
  131. package/src/modes/controllers/extension-ui-controller.ts +19 -2
  132. package/src/modes/controllers/input-controller.ts +18 -3
  133. package/src/modes/controllers/selector-controller.ts +83 -37
  134. package/src/modes/interactive-mode.ts +62 -3
  135. package/src/modes/irc-observation-ledger.ts +71 -15
  136. package/src/modes/runtime-init.ts +19 -2
  137. package/src/modes/types.ts +2 -0
  138. package/src/modes/utils/ui-helpers.ts +94 -1
  139. package/src/prompts/agents/architect.md +9 -1
  140. package/src/prompts/agents/critic.md +9 -0
  141. package/src/prompts/system/system-prompt.md +0 -1
  142. package/src/prompts/tools/read.md +4 -2
  143. package/src/runtime-mcp/smithery-auth.ts +17 -2
  144. package/src/runtime-mcp/smithery-connect.ts +11 -1
  145. package/src/sdk/broker/lifecycle.ts +25 -6
  146. package/src/sdk/bus/control-drain-lease.ts +104 -0
  147. package/src/sdk/bus/index.ts +341 -38
  148. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  149. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  150. package/src/sdk/bus/reconciliation-store.ts +224 -0
  151. package/src/sdk/host/control/dispatch.ts +5 -1
  152. package/src/sdk/host/control/operations.ts +1 -1
  153. package/src/sdk/host/host.ts +23 -1
  154. package/src/sdk/host/query/handlers.ts +55 -0
  155. package/src/sdk/mcp/server.ts +46 -18
  156. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  157. package/src/sdk/protocol/operation-registry.ts +7 -2
  158. package/src/sdk/session.ts +13 -1
  159. package/src/session/agent-session.ts +466 -50
  160. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  161. package/src/session/session-manager.ts +764 -17
  162. package/src/session/streaming-output.ts +262 -43
  163. package/src/setup/provider-onboarding.ts +19 -4
  164. package/src/skill-state/active-state.ts +0 -10
  165. package/src/skill-state/workflow-hud.ts +25 -2
  166. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  167. package/src/tools/ask.ts +89 -69
  168. package/src/tools/browser/launch.ts +41 -6
  169. package/src/tools/fetch.ts +114 -23
  170. package/src/tools/output-meta.ts +176 -9
  171. package/src/tools/path-utils.ts +5 -1
  172. package/src/tools/read-internals.ts +44 -0
  173. package/src/tools/read.ts +1542 -310
  174. package/src/tools/resource-gc.ts +182 -18
  175. package/src/tools/tool-result.ts +14 -1
  176. package/src/web/search/providers/codex.ts +1 -1
  177. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  178. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  179. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  180. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  181. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  182. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
@@ -114,8 +114,12 @@ export interface TruncationResult {
114
114
  elidedLines?: number;
115
115
  lastLinePartial?: boolean;
116
116
  firstLineExceedsLimit?: boolean;
117
+ lastLineExceedsLimit?: boolean;
117
118
  }
118
119
 
120
+ /** Direction vocabulary used by callers that select which end of content to retain. */
121
+ export type TruncationDirection = "head" | "last" | "both";
122
+
119
123
  export interface TruncationOptions {
120
124
  /** Maximum number of lines (default: 3000) */
121
125
  maxLines?: number;
@@ -131,6 +135,8 @@ export interface TruncationOptions {
131
135
  * window receives `maxLines - maxHeadLines`. Default `floor(maxLines/2)`.
132
136
  */
133
137
  maxHeadLines?: number;
138
+ /** Direction to dispatch through truncateContent (head, last, or both). */
139
+ direction?: TruncationDirection;
134
140
  }
135
141
 
136
142
  /** Result from byte-level truncation helpers. */
@@ -452,6 +458,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
452
458
  outputBytes: tail.bytes,
453
459
  lastLinePartial: true,
454
460
  firstLineExceedsLimit: false,
461
+ lastLineExceedsLimit: true,
455
462
  };
456
463
  }
457
464
  break;
@@ -474,6 +481,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
474
481
  outputBytes: tail.bytes,
475
482
  lastLinePartial: true,
476
483
  firstLineExceedsLimit: false,
484
+ lastLineExceedsLimit: true,
477
485
  };
478
486
  }
479
487
  break;
@@ -516,14 +524,129 @@ export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: numb
516
524
  }
517
525
 
518
526
  /**
519
- * Truncate content keeping a head window and a tail window, eliding the middle.
527
+ * A retained source segment returned by {@link truncateMiddleWindows}.
528
+ * Line coordinates are 1-indexed and inclusive.
529
+ */
530
+ export type ReadSegment =
531
+ | {
532
+ kind: "lines";
533
+ content: string;
534
+ lines: number;
535
+ bytes: number;
536
+ origin: { startLine: number; endLine: number };
537
+ lastLinePartial: false;
538
+ }
539
+ | {
540
+ kind: "partial-line";
541
+ content: string;
542
+ lines: 1;
543
+ bytes: number;
544
+ origin: { startLine: number; endLine: number };
545
+ sourceLineBytes: number;
546
+ lastLinePartial: true;
547
+ };
548
+
549
+ /** The actual windows retained by middle truncation. */
550
+ export interface ReadWindow {
551
+ kind: "full" | "head-only" | "tail-only" | "middle";
552
+ head?: ReadSegment;
553
+ tail?: ReadSegment;
554
+ // With normalized head/tail line budgets, an overlap cannot occur: a
555
+ // source that can fit both windows returns `full`, otherwise their kept
556
+ // line counts sum to less than the source. Retain this defensive value for
557
+ // custom/future budgets and wire compatibility.
558
+ overlap: "disjoint" | "adjacent" | "overlapping";
559
+ elidedLines: number;
560
+ elidedBytes: number;
561
+ totalLines: number;
562
+ totalBytes: number;
563
+ /** @deprecated Non-enumerable compatibility reason for older consumers. */
564
+ truncatedBy?: "lines" | "bytes" | "middle";
565
+ }
566
+
567
+ function outputLineCount(result: TruncationResult): number {
568
+ return result.outputLines ?? (result.content.length === 0 ? 0 : countNewlines(result.content) + 1);
569
+ }
570
+
571
+ function outputByteCount(result: TruncationResult): number {
572
+ return result.outputBytes ?? Buffer.byteLength(result.content, "utf-8");
573
+ }
574
+
575
+ function lastSourceLineBytes(content: string): number {
576
+ const lineStart = content.lastIndexOf(NL) + 1;
577
+ return Buffer.byteLength(content.slice(lineStart), "utf-8");
578
+ }
579
+
580
+ function makeReadSegment(
581
+ result: TruncationResult,
582
+ startLine: number,
583
+ totalLines: number,
584
+ sourceContent: string = result.content,
585
+ ): ReadSegment | undefined {
586
+ const lines = outputLineCount(result);
587
+ if (lines <= 0) return undefined;
588
+ const bytes = outputByteCount(result);
589
+ const endLine = Math.min(totalLines, startLine + lines - 1);
590
+ if (result.lastLinePartial) {
591
+ const partialLine = endLine;
592
+ return {
593
+ kind: "partial-line",
594
+ content: result.content,
595
+ lines: 1,
596
+ bytes,
597
+ origin: { startLine: partialLine, endLine: partialLine },
598
+ sourceLineBytes: lastSourceLineBytes(sourceContent),
599
+ lastLinePartial: true,
600
+ };
601
+ }
602
+ return {
603
+ kind: "lines",
604
+ content: result.content,
605
+ lines,
606
+ bytes,
607
+ origin: { startLine, endLine },
608
+ lastLinePartial: false,
609
+ };
610
+ }
611
+
612
+ function withMiddleWindowReason(windows: ReadWindow, truncatedBy: "lines" | "bytes" | "middle"): ReadWindow {
613
+ Object.defineProperty(windows, "truncatedBy", { value: truncatedBy, enumerable: false });
614
+ return windows;
615
+ }
616
+
617
+ function fullReadWindow(
618
+ content: string,
619
+ totalLines: number,
620
+ totalBytes: number,
621
+ overlap: ReadWindow["overlap"] = "disjoint",
622
+ ): ReadWindow {
623
+ return {
624
+ kind: "full",
625
+ head: {
626
+ kind: "lines",
627
+ content,
628
+ lines: totalLines,
629
+ bytes: totalBytes,
630
+ origin: { startLine: 1, endLine: totalLines },
631
+ lastLinePartial: false,
632
+ },
633
+ overlap,
634
+ elidedLines: 0,
635
+ elidedBytes: 0,
636
+ totalLines,
637
+ totalBytes,
638
+ };
639
+ }
640
+
641
+ /**
642
+ * Truncate content while exposing the exact retained head/tail windows.
520
643
  *
521
- * The combined output is `<head>\n<marker>\n<tail>` when truncation is needed.
522
- * `maxHeadBytes` defaults to `floor(maxBytes / 2)`; the tail receives the
523
- * remainder. Falls back to `truncateTail` / `truncateHead` if either side's
524
- * budget is empty or the content already fits.
644
+ * The budget split and fallback ordering intentionally mirror truncateMiddle.
645
+ * The overlap classification is performed before resolving an overlap to a
646
+ * full window, so callers can distinguish disjoint, adjacent and overlapping
647
+ * candidate windows even when no marker is needed.
525
648
  */
526
- export function truncateMiddle(content: string, options: TruncationOptions = {}): TruncationResult {
649
+ export function truncateMiddleWindows(content: string, options: TruncationOptions = {}): ReadWindow {
527
650
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
528
651
  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
529
652
  const headBytes = options.maxHeadBytes ?? Math.floor(maxBytes / 2);
@@ -533,57 +656,153 @@ export function truncateMiddle(content: string, options: TruncationOptions = {})
533
656
 
534
657
  const totalBytes = Buffer.byteLength(content, "utf-8");
535
658
  const totalLines = countNewlines(content) + 1;
659
+ if (totalBytes <= maxBytes && totalLines <= maxLines) return fullReadWindow(content, totalLines, totalBytes);
660
+
661
+ const oneSided = (kind: "head-only" | "tail-only", result: TruncationResult, startLine: number): ReadWindow => {
662
+ const lines = outputLineCount(result);
663
+ const bytes = outputByteCount(result);
664
+ const segment = makeReadSegment(result, startLine, totalLines, content);
665
+
666
+ const windows: ReadWindow = {
667
+ kind,
668
+ ...(kind === "head-only" ? (segment ? { head: segment } : {}) : segment ? { tail: segment } : {}),
669
+ overlap: "disjoint",
670
+ elidedLines: Math.max(0, totalLines - lines),
671
+ elidedBytes: Math.max(0, totalBytes - bytes),
672
+ totalLines,
673
+ totalBytes,
674
+ };
675
+ return withMiddleWindowReason(windows, result.truncatedBy === "lines" ? "lines" : "bytes");
676
+ };
536
677
 
537
- if (totalBytes <= maxBytes && totalLines <= maxLines) {
538
- return noTruncResult(content, totalLines, totalBytes);
539
- }
540
-
541
- // Degenerate budgets → fall back to one-sided truncation.
678
+ // Keep this order identical to truncateMiddle: degenerate budgets are
679
+ // resolved before either one-sided truncator is called.
542
680
  if (headBytes <= 0 || headLines <= 0) {
543
- return truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
681
+ const tail = truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
682
+ return oneSided("tail-only", tail, totalLines - outputLineCount(tail) + 1);
544
683
  }
545
684
  if (tailBytes <= 0 || tailLines <= 0) {
546
- return truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
685
+ const head = truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
686
+ return oneSided("head-only", head, 1);
547
687
  }
548
688
 
549
689
  const head = truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
550
690
  const tail = truncateTail(content, { maxBytes: tailBytes, maxLines: tailLines });
551
-
552
- const headLinesKept = head.outputLines ?? 0;
553
- const tailLinesKept = tail.outputLines ?? 0;
554
- const headBytesKept = head.outputBytes ?? Buffer.byteLength(head.content, "utf-8");
555
- const tailBytesKept = tail.outputBytes ?? Buffer.byteLength(tail.content, "utf-8");
691
+ const headLinesKept = outputLineCount(head);
692
+ const tailLinesKept = outputLineCount(tail);
693
+ const headBytesKept = outputByteCount(head);
694
+ const tailBytesKept = outputByteCount(tail);
556
695
 
557
696
  // Head unusable (first line exceeds budget) → tail-only.
558
- if (headLinesKept === 0 || head.firstLineExceedsLimit) return tail;
697
+ if (headLinesKept === 0 || head.firstLineExceedsLimit) {
698
+ return oneSided("tail-only", tail, totalLines - tailLinesKept + 1);
699
+ }
559
700
  // Tail unusable → head-only.
560
- if (tailLinesKept === 0) return head;
561
- // Windows overlap → no meaningful elision; return content untruncated.
562
- if (headLinesKept + tailLinesKept >= totalLines) {
563
- return noTruncResult(content, totalLines, totalBytes);
701
+ if (tailLinesKept === 0) return oneSided("head-only", head, 1);
702
+
703
+ const headEnd = headLinesKept;
704
+ const tailStart = totalLines - tailLinesKept + 1;
705
+ const overlap: ReadWindow["overlap"] =
706
+ headEnd + 1 < tailStart ? "disjoint" : headEnd + 1 === tailStart ? "adjacent" : "overlapping";
707
+ const tailPartial = tail.lastLinePartial === true;
708
+
709
+ // Adjacent/overlapping complete windows cover the whole source. Return one
710
+ // full segment so renderers never insert a marker for an overlap.
711
+ if (headLinesKept + tailLinesKept >= totalLines && !tailPartial) {
712
+ return fullReadWindow(content, totalLines, totalBytes, overlap);
564
713
  }
565
714
 
566
- const elidedLines = totalLines - headLinesKept - tailLinesKept;
567
- // `totalBytes - headBytesKept - tailBytesKept` includes newline separators
568
- // between the kept windows and the elided region; close enough for a notice.
569
- const elidedBytes = Math.max(0, totalBytes - headBytesKept - tailBytesKept);
570
- const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
571
- const composed = `${head.content}\n${marker}\n${tail.content}`;
572
- const markerBytes = Buffer.byteLength(marker, "utf-8");
715
+ const headWindow = makeReadSegment(head, 1, totalLines, content);
716
+ const tailWindow = makeReadSegment(tail, tailStart, totalLines, content);
717
+ if (!headWindow || !tailWindow) return fullReadWindow(content, totalLines, totalBytes, overlap);
718
+
719
+ const partialSourceBytes = tailWindow.kind === "partial-line" ? tailWindow.sourceLineBytes : tailWindow.bytes;
720
+ const elidedLines = tailPartial
721
+ ? Math.max(0, tailStart - headEnd - 1)
722
+ : Math.max(0, totalLines - headLinesKept - tailLinesKept);
723
+ const elidedBytes = tailPartial
724
+ ? Math.max(0, totalBytes - headBytesKept - partialSourceBytes)
725
+ : Math.max(0, totalBytes - headBytesKept - tailBytesKept);
726
+ return withMiddleWindowReason(
727
+ {
728
+ kind: "middle",
729
+ head: headWindow,
730
+ tail: tailWindow,
731
+ overlap,
732
+ elidedLines,
733
+ elidedBytes,
734
+ totalLines,
735
+ totalBytes,
736
+ },
737
+ "middle",
738
+ );
739
+ }
573
740
 
574
- return {
575
- content: composed,
576
- truncated: true,
577
- truncatedBy: "middle",
578
- totalLines,
579
- totalBytes,
580
- outputLines: headLinesKept + tailLinesKept + 1,
581
- outputBytes: headBytesKept + tailBytesKept + markerBytes + 2,
582
- elidedLines,
583
- elidedBytes,
584
- lastLinePartial: tail.lastLinePartial,
585
- firstLineExceedsLimit: false,
586
- };
741
+ /**
742
+ * Truncate content keeping a head window and a tail window, eliding the middle.
743
+ * The composed return shape remains field-for-field compatible with the
744
+ * historical implementation; callers that need coordinates use
745
+ * truncateMiddleWindows directly.
746
+ */
747
+ export function truncateMiddle(content: string, options: TruncationOptions = {}): TruncationResult {
748
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
749
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
750
+ const headBytes = options.maxHeadBytes ?? Math.floor(maxBytes / 2);
751
+ const tailBytes = Math.max(0, maxBytes - headBytes);
752
+ const headLines = options.maxHeadLines ?? Math.max(1, Math.floor(maxLines / 2));
753
+ const tailLines = Math.max(0, maxLines - headLines);
754
+ const windows = truncateMiddleWindows(content, options);
755
+
756
+ switch (windows.kind) {
757
+ case "full":
758
+ return noTruncResult(content, windows.totalLines, windows.totalBytes);
759
+ case "tail-only":
760
+ return truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
761
+ case "head-only":
762
+ return truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
763
+ case "middle": {
764
+ const head = windows.head;
765
+ const tail = windows.tail;
766
+ if (!head || !tail) return noTruncResult(content, windows.totalLines, windows.totalBytes);
767
+ if (head.lines + tail.lines >= windows.totalLines)
768
+ return noTruncResult(content, windows.totalLines, windows.totalBytes);
769
+ const legacyElidedLines = Math.max(0, windows.totalLines - head.lines - tail.lines);
770
+ const legacyElidedBytes =
771
+ tail.kind === "partial-line"
772
+ ? Math.max(0, windows.totalBytes - head.bytes - tail.bytes)
773
+ : windows.elidedBytes;
774
+ const marker = formatMiddleElisionMarker(legacyElidedLines, legacyElidedBytes);
775
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
776
+ return {
777
+ content: `${head.content}\n${marker}\n${tail.content}`,
778
+ truncated: true,
779
+ truncatedBy: "middle",
780
+ totalLines: windows.totalLines,
781
+ totalBytes: windows.totalBytes,
782
+ outputLines: head.lines + tail.lines + 1,
783
+ outputBytes: head.bytes + tail.bytes + markerBytes + 2,
784
+ elidedLines: legacyElidedLines,
785
+ elidedBytes: legacyElidedBytes,
786
+ lastLinePartial: tail.lastLinePartial,
787
+ firstLineExceedsLimit: false,
788
+ ...(tail.lastLinePartial ? { lastLineExceedsLimit: true } : {}),
789
+ };
790
+ }
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Dispatch truncation using the caller-facing direction vocabulary.
796
+ */
797
+ export function truncateContent(content: string, options: TruncationOptions = {}): TruncationResult {
798
+ switch (options.direction) {
799
+ case "last":
800
+ return truncateTail(content, options);
801
+ case "both":
802
+ return truncateMiddle(content, options);
803
+ default:
804
+ return truncateHead(content, options);
805
+ }
587
806
  }
588
807
 
589
808
  // =============================================================================
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { getAgentDbPath, getAgentDir } from "@gajae-code/utils";
5
5
  import { YAML } from "bun";
6
6
  import { type ModelsConfig, ModelsConfigSchema } from "../config/models-config-schema";
7
+ import { compareRankedProviders, famousProviderIndex } from "../config/provider-ranking";
7
8
  import { AuthStorage } from "../session/auth-storage";
8
9
  import providerPresets from "./provider-presets.json";
9
10
 
@@ -81,11 +82,25 @@ export function findProviderPreset(value: string | undefined): ProviderPreset |
81
82
  return PROVIDER_PRESETS.find(preset => preset.id === normalized || preset.aliases.includes(normalized));
82
83
  }
83
84
 
85
+ function providerPresetRankingId(preset: ProviderPreset): string {
86
+ return (
87
+ [preset.providerId, preset.id, ...preset.aliases].find(id => famousProviderIndex(id) !== undefined) ?? preset.id
88
+ );
89
+ }
90
+
84
91
  export function formatProviderPresetList(): string {
85
- return PROVIDER_PRESETS.map(preset => {
86
- const aliases = preset.aliases.length > 0 ? ` (aliases: ${preset.aliases.join(", ")})` : "";
87
- return `${preset.id}${aliases}: ${preset.description}`;
88
- }).join("\n");
92
+ return [...PROVIDER_PRESETS]
93
+ .sort((left, right) =>
94
+ compareRankedProviders(
95
+ { id: providerPresetRankingId(left), label: left.name, authState: "none" },
96
+ { id: providerPresetRankingId(right), label: right.name, authState: "none" },
97
+ ),
98
+ )
99
+ .map(preset => {
100
+ const aliases = preset.aliases.length > 0 ? ` (aliases: ${preset.aliases.join(", ")})` : "";
101
+ return `${preset.id}${aliases}: ${preset.description}`;
102
+ })
103
+ .join("\n");
89
104
  }
90
105
 
91
106
  export function parseModelList(values: readonly string[]): string[] {
@@ -71,8 +71,6 @@ export interface SkillActiveEntry {
71
71
  handoff_at?: string;
72
72
  active_subskills?: ActiveSubskillEntry[];
73
73
  source_state_revision?: number;
74
- /** Durable ordering token from the source workflow mode-state, not this cache file's revision. */
75
- committed_mode_state_revision?: number;
76
74
  }
77
75
 
78
76
  export interface SkillActiveState {
@@ -116,8 +114,6 @@ export interface SyncSkillActiveStateOptions {
116
114
  handoff_at?: string;
117
115
  active_subskills?: ActiveSubskillEntry[];
118
116
  sourceRevision?: number;
119
- /** Durable ordering token from the source workflow mode-state, not this cache file's revision. */
120
- committedModeRevision?: number;
121
117
  }
122
118
 
123
119
  const HUD_TEXT_LIMIT = 80;
@@ -789,9 +785,6 @@ async function persistActiveEntry(
789
785
  await writeActiveEntry(cwd, sessionScope, entry.skill, entry, {
790
786
  cwd,
791
787
  audit: activeStateWriterAudit("write-active-entry", sessionScope),
792
- ...(typeof entry.committed_mode_state_revision === "number"
793
- ? { orderingRevision: entry.committed_mode_state_revision }
794
- : {}),
795
788
  });
796
789
  }
797
790
  }
@@ -870,9 +863,6 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
870
863
  ? { active_subskills: preservedActiveSubskills }
871
864
  : {}),
872
865
  ...(typeof options.sourceRevision === "number" ? { source_state_revision: options.sourceRevision } : {}),
873
- ...(typeof options.committedModeRevision === "number"
874
- ? { committed_mode_state_revision: options.committedModeRevision }
875
- : {}),
876
866
  };
877
867
  const sessionScope = { sessionId: options.sessionId };
878
868
  await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry);
@@ -23,6 +23,9 @@ interface RalplanHudState extends WorkflowGateHudState {
23
23
  iteration?: number;
24
24
  iterationFromIndex?: number;
25
25
  stages?: string;
26
+ architectPasses?: number;
27
+ criticPasses?: number;
28
+ reviewPassBudget?: number;
26
29
  verdict?: string;
27
30
  latestSummary?: string;
28
31
  pendingApproval?: boolean;
@@ -194,13 +197,31 @@ export function deriveDeepInterviewHud(
194
197
  export function buildRalplanHudSummary(state: RalplanHudState): WorkflowHudSummary {
195
198
  const verdict = state.verdict?.toUpperCase();
196
199
  const verdictSeverity =
197
- verdict === "BLOCK"
200
+ verdict === "BLOCK" || verdict === "REJECT"
198
201
  ? "blocked"
199
202
  : verdict === "ITERATE" || verdict === "WATCH"
200
203
  ? "warning"
201
- : verdict === "APPROVE" || verdict === "CLEAR"
204
+ : verdict === "APPROVE" || verdict === "CLEAR" || verdict === "OKAY"
202
205
  ? "success"
203
206
  : undefined;
207
+ const reviewPassChip = (
208
+ label: "arch" | "crit",
209
+ passes: number | undefined,
210
+ priority: number,
211
+ ): WorkflowHudChip | null => {
212
+ if (
213
+ state.pendingApproval ||
214
+ typeof passes !== "number" ||
215
+ !Number.isFinite(passes) ||
216
+ passes <= 0 ||
217
+ typeof state.reviewPassBudget !== "number" ||
218
+ !Number.isFinite(state.reviewPassBudget) ||
219
+ state.reviewPassBudget <= 0
220
+ ) {
221
+ return null;
222
+ }
223
+ return chip(label, `${passes}/${state.reviewPassBudget}`, priority);
224
+ };
204
225
  return {
205
226
  version: 1,
206
227
  summary: state.latestSummary,
@@ -217,6 +238,8 @@ export function buildRalplanHudSummary(state: RalplanHudState): WorkflowHudSumma
217
238
  30,
218
239
  ),
219
240
  chip("stages", state.stages, 35),
241
+ reviewPassChip("arch", state.architectPasses, 36),
242
+ reviewPassChip("crit", state.criticPasses, 38),
220
243
  chip("verdict", verdict, 40, verdictSeverity),
221
244
  ]),
222
245
  ...(state.updatedAt ? { updated_at: state.updatedAt } : {}),
@@ -57,6 +57,9 @@ const BASH_OPAQUE_INTERPRETER_WRITE_RE =
57
57
  const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
58
58
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:<<[-]?\s*['"]?\w+['"]?)[\s\S]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
59
59
  const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?dd\b([^;&|\n]*)/gi;
60
+ /** Literal `sh|bash|zsh -c '<script>'` payloads whose nested script must also be scanned. */
61
+ const BASH_NESTED_SHELL_RE =
62
+ /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(?:ba|z|da)?sh\s+(?:-[A-Za-z]+\s+)*-[A-Za-z]*c\s+(?:(')([^']*)'|(")([^"]*)")/g;
60
63
 
61
64
  type ToolWithEditMode = AgentTool & {
62
65
  mode?: unknown;
@@ -77,6 +80,12 @@ export interface WorkflowMutationGuardInput {
77
80
  interface ExtractedTargets {
78
81
  paths: string[];
79
82
  unknown: boolean;
83
+ /**
84
+ * Set when a bash command was recognized as performing a filesystem write.
85
+ * During a planning phase an unrecognized bash command is allowed (reading
86
+ * and inspection must not be blocked); only a recognized mutation blocks.
87
+ */
88
+ explicitMutation?: boolean;
80
89
  }
81
90
 
82
91
  export interface WorkflowMutationDecision {
@@ -382,11 +391,34 @@ function cleanShellWord(value: string): string {
382
391
  return value.replace(/^['"]|['"]$/g, "");
383
392
  }
384
393
 
394
+ /**
395
+ * Blank out single-quoted spans so shell metacharacters inside a literal
396
+ * argument value (e.g. `--value 'uses `x`; a > b'`) are not misread as
397
+ * redirections or separators. Single quotes suppress every expansion in POSIX
398
+ * shells, so their contents are always inert data. Double-quoted spans are left
399
+ * intact because they still expand `$(...)` and backticks.
400
+ */
401
+ function maskSingleQuotedSpans(command: string): string {
402
+ let masked = "";
403
+ let inSingle = false;
404
+ for (const character of command) {
405
+ if (character === "'") {
406
+ inSingle = !inSingle;
407
+ masked += character;
408
+ continue;
409
+ }
410
+ masked += inSingle && character !== "\n" ? " " : character;
411
+ }
412
+ // An unbalanced quote means the parse is unreliable; keep the original text
413
+ // so the scanner stays fail-closed rather than blind.
414
+ return inSingle ? command : masked;
415
+ }
416
+
385
417
  function isDeviceSinkPath(value: string): boolean {
386
418
  return DEVICE_SINK_PATHS.has(cleanShellWord(value));
387
419
  }
388
420
 
389
- function extractBashTargets(args: unknown): ExtractedTargets {
421
+ function extractBashTargets(args: unknown, depth = 0): ExtractedTargets {
390
422
  const record = getRecord(args);
391
423
  const command = safeString(record?.command);
392
424
  const targets: ExtractedTargets = { paths: [], unknown: false };
@@ -394,19 +426,40 @@ function extractBashTargets(args: unknown): ExtractedTargets {
394
426
  targets.unknown = true;
395
427
  return targets;
396
428
  }
429
+ // A literal `sh|bash|zsh -c '<script>'` payload is a real command list. Its
430
+ // mutations never match the top-level patterns, so recurse into it; anything
431
+ // deeper than a bounded depth is not statically analyzable and fails closed.
432
+ for (const match of command.matchAll(BASH_NESTED_SHELL_RE)) {
433
+ if (depth >= 2) {
434
+ targets.explicitMutation = true;
435
+ targets.unknown = true;
436
+ break;
437
+ }
438
+ const nested = extractBashTargets({ command: match[2] ?? match[4] ?? "" }, depth + 1);
439
+ for (const nestedPath of nested.paths) addPath(targets, nestedPath);
440
+ if (nested.unknown) targets.unknown = true;
441
+ if (nested.explicitMutation) targets.explicitMutation = true;
442
+ }
443
+ // Nested scripts were read from the raw text above; every scanner below works
444
+ // on the masked view so quoted argument data cannot look like a redirection.
445
+ const scanned = maskSingleQuotedSpans(command);
397
446
  if (BASH_OPAQUE_INTERPRETER_WRITE_RE.test(command) || BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(command)) {
447
+ targets.explicitMutation = true;
398
448
  targets.unknown = true;
399
449
  }
400
450
  // `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
401
- if (BASH_EXEC_REDIRECT_RE.test(command)) {
451
+ if (BASH_EXEC_REDIRECT_RE.test(scanned)) {
452
+ targets.explicitMutation = true;
402
453
  targets.unknown = true;
403
454
  }
404
- for (const match of command.matchAll(BASH_EXTENDED_WRITE_RE)) {
455
+ for (const match of scanned.matchAll(BASH_EXTENDED_WRITE_RE)) {
456
+ targets.explicitMutation = true;
405
457
  const cleaned = cleanShellWord(match[1] ?? "");
406
458
  if (!cleaned) targets.unknown = true;
407
459
  else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
408
460
  }
409
- for (const match of command.matchAll(BASH_DD_OUTPUT_RE)) {
461
+ for (const match of scanned.matchAll(BASH_DD_OUTPUT_RE)) {
462
+ targets.explicitMutation = true;
410
463
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
411
464
  // Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
412
465
  const outputs = parts.filter(part => part.startsWith("of="));
@@ -417,15 +470,17 @@ function extractBashTargets(args: unknown): ExtractedTargets {
417
470
  else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
418
471
  }
419
472
  }
420
- for (const match of command.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
473
+ for (const match of scanned.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
421
474
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
422
475
  const hasInPlaceFlag = parts.some(part => /^-.*i/.test(part));
423
476
  if (!hasInPlaceFlag) continue;
477
+ targets.explicitMutation = true;
424
478
  const target = [...parts].reverse().find(part => part && !part.startsWith("-"));
425
479
  if (target) addPath(targets, target);
426
480
  else targets.unknown = true;
427
481
  }
428
- for (const match of command.matchAll(BASH_MUTATION_COMMAND_RE)) {
482
+ for (const match of scanned.matchAll(BASH_MUTATION_COMMAND_RE)) {
483
+ targets.explicitMutation = true;
429
484
  const redirected = match[2]?.trim();
430
485
  if (redirected) {
431
486
  const cleaned = cleanShellWord(redirected);
@@ -666,7 +721,10 @@ export async function getWorkflowMutationDecision(
666
721
  }
667
722
  if (input.forceOverride) return { blocked: false, targets: [] };
668
723
  const message = planningPhaseBlockMessage(planning.skill);
669
- if (targets.unknown) {
724
+ // Bash during a planning phase blocks only when a filesystem mutation was
725
+ // actually recognized. Inspection, reads, and CLI invocations the scanner does
726
+ // not model stay usable; other tools keep failing closed on unknown targets.
727
+ if (targets.unknown && (input.tool.name !== "bash" || targets.explicitMutation)) {
670
728
  return {
671
729
  blocked: true,
672
730
  message,