@gajae-code/coding-agent 0.5.2 → 0.5.4

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 (99) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/config/model-profiles.d.ts +10 -0
  4. package/dist/types/dap/client.d.ts +2 -1
  5. package/dist/types/edit/read-file.d.ts +6 -0
  6. package/dist/types/eval/js/context-manager.d.ts +3 -0
  7. package/dist/types/eval/js/executor.d.ts +1 -0
  8. package/dist/types/exec/bash-executor.d.ts +2 -0
  9. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  10. package/dist/types/lsp/types.d.ts +2 -0
  11. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  12. package/dist/types/modes/components/model-selector.d.ts +2 -0
  13. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  14. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  16. package/dist/types/modes/interactive-mode.d.ts +1 -0
  17. package/dist/types/modes/types.d.ts +1 -0
  18. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  19. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  20. package/dist/types/runtime-mcp/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +29 -1
  22. package/dist/types/session/artifacts.d.ts +4 -1
  23. package/dist/types/session/streaming-output.d.ts +12 -0
  24. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  25. package/dist/types/tools/bash.d.ts +1 -0
  26. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  27. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  28. package/dist/types/web/search/providers/codex.d.ts +4 -4
  29. package/package.json +7 -7
  30. package/src/async/job-manager.ts +181 -43
  31. package/src/config/file-lock.ts +9 -1
  32. package/src/config/model-profile-activation.ts +71 -3
  33. package/src/config/model-profiles.ts +39 -14
  34. package/src/dap/client.ts +105 -64
  35. package/src/dap/session.ts +44 -7
  36. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -2
  37. package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -2
  38. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -2
  39. package/src/edit/read-file.ts +19 -1
  40. package/src/eval/js/context-manager.ts +228 -65
  41. package/src/eval/js/executor.ts +2 -0
  42. package/src/eval/js/index.ts +1 -0
  43. package/src/eval/js/worker-core.ts +10 -6
  44. package/src/eval/py/executor.ts +68 -19
  45. package/src/eval/py/kernel.ts +46 -22
  46. package/src/eval/py/runner.py +68 -14
  47. package/src/exec/bash-executor.ts +49 -13
  48. package/src/gjc-runtime/deep-interview-runtime.ts +14 -13
  49. package/src/gjc-runtime/ralplan-runtime.ts +10 -0
  50. package/src/gjc-runtime/state-runtime.ts +73 -0
  51. package/src/gjc-runtime/tmux-gc.ts +86 -37
  52. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  53. package/src/gjc-runtime/ultragoal-runtime.ts +8 -4
  54. package/src/internal-urls/artifact-protocol.ts +10 -1
  55. package/src/internal-urls/docs-index.generated.ts +2 -2
  56. package/src/lsp/client.ts +64 -26
  57. package/src/lsp/index.ts +2 -1
  58. package/src/lsp/lspmux.ts +33 -9
  59. package/src/lsp/types.ts +2 -0
  60. package/src/modes/bridge/bridge-mode.ts +21 -0
  61. package/src/modes/components/assistant-message.ts +10 -2
  62. package/src/modes/components/bash-execution.ts +5 -1
  63. package/src/modes/components/eval-execution.ts +5 -1
  64. package/src/modes/components/model-selector.ts +34 -2
  65. package/src/modes/components/oauth-selector.ts +5 -0
  66. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  67. package/src/modes/components/skill-message.ts +24 -16
  68. package/src/modes/components/tool-execution.ts +6 -0
  69. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  70. package/src/modes/controllers/input-controller.ts +19 -0
  71. package/src/modes/controllers/selector-controller.ts +6 -1
  72. package/src/modes/interactive-mode.ts +13 -0
  73. package/src/modes/types.ts +1 -0
  74. package/src/modes/utils/ui-helpers.ts +5 -2
  75. package/src/prompts/agents/executor.md +1 -1
  76. package/src/runtime/process-lifecycle.ts +400 -0
  77. package/src/runtime-mcp/manager.ts +164 -50
  78. package/src/runtime-mcp/transports/http.ts +12 -11
  79. package/src/runtime-mcp/transports/stdio.ts +64 -38
  80. package/src/runtime-mcp/types.ts +3 -0
  81. package/src/sdk.ts +27 -0
  82. package/src/session/agent-session.ts +271 -25
  83. package/src/session/artifacts.ts +17 -2
  84. package/src/session/blob-store.ts +36 -2
  85. package/src/session/session-manager.ts +29 -13
  86. package/src/session/streaming-output.ts +95 -3
  87. package/src/setup/model-onboarding-guidance.ts +10 -3
  88. package/src/skill-state/active-state.ts +79 -7
  89. package/src/slash-commands/builtin-registry.ts +30 -3
  90. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  91. package/src/tools/archive-reader.ts +10 -1
  92. package/src/tools/bash.ts +11 -4
  93. package/src/tools/browser/registry.ts +17 -1
  94. package/src/tools/browser/tab-supervisor.ts +22 -0
  95. package/src/tools/browser.ts +38 -4
  96. package/src/tools/cron.ts +2 -6
  97. package/src/tools/read.ts +11 -12
  98. package/src/tools/sqlite-reader.ts +19 -5
  99. package/src/web/search/providers/codex.ts +6 -5
@@ -8,6 +8,13 @@ function sanitizeOutputChunk(rawChunk: string): string {
8
8
  return sanitizeWithOptionalSixelPassthrough(rawChunk, sanitizeText);
9
9
  }
10
10
 
11
+ /**
12
+ * Flush threshold for the opt-in sanitize-coalescing path (F21). When coalescing is enabled, raw
13
+ * chunks accumulate until they reach this many chars, then are sanitized + delivered as one batch,
14
+ * so many-small-chunk output pays one sanitize pass per batch instead of one per tiny chunk.
15
+ */
16
+ const COALESCE_FLUSH_CHARS = 64 * 1024;
17
+
11
18
  // =============================================================================
12
19
  // Constants
13
20
  // =============================================================================
@@ -15,6 +22,7 @@ function sanitizeOutputChunk(rawChunk: string): string {
15
22
  export const DEFAULT_MAX_LINES = 3000;
16
23
  export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
17
24
  export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line
25
+ export const DEFAULT_ARTIFACT_MAX_BYTES = 10 * 1024 * 1024; // 10MB
18
26
 
19
27
  const NL = "\n";
20
28
 
@@ -41,6 +49,8 @@ export interface OutputSummary {
41
49
  columnTruncatedLines?: number;
42
50
  /** Artifact ID for internal URL access (artifact://<id>) when truncated */
43
51
  artifactId?: string;
52
+ /** Bytes omitted from artifact storage after the artifact hard cap was reached. */
53
+ artifactTruncatedBytes?: number;
44
54
  }
45
55
 
46
56
  export interface OutputSinkOptions {
@@ -61,6 +71,8 @@ export interface OutputSinkOptions {
61
71
  * writes still respect the budget. Default 0 = no per-line cap.
62
72
  */
63
73
  maxColumns?: number;
74
+ /** Hard cap for artifact writes/pending replay. Default DEFAULT_ARTIFACT_MAX_BYTES. */
75
+ artifactMaxBytes?: number;
64
76
  onChunk?: (chunk: string) => void;
65
77
  /** Minimum ms between onChunk calls. 0 = every chunk (default). */
66
78
  chunkThrottleMs?: number;
@@ -75,6 +87,13 @@ export interface OutputSinkOptions {
75
87
  * relative to the sink (the sink does not catch errors from this callback).
76
88
  */
77
89
  onRawChunk?: (chunk: string) => void;
90
+ /**
91
+ * Opt-in (F21): when true, sanitization + live callback delivery + retention are coalesced over
92
+ * batched raw chunks instead of run per chunk, bounding sync CPU for many-small-chunk output. The
93
+ * raw artifact mirror stays byte-correct. Defaults to the PI_OUTPUT_SANITIZE_COALESCE env flag
94
+ * (default OFF — the per-chunk path is byte-identical to historical behavior).
95
+ */
96
+ coalesceSanitize?: boolean;
78
97
  }
79
98
 
80
99
  export interface TruncationResult {
@@ -668,6 +687,9 @@ export class OutputSink {
668
687
  #sawData = false;
669
688
  #truncated = false;
670
689
  #lastChunkTime = 0;
690
+ #artifactBytes = 0;
691
+ #artifactTruncatedBytes = 0;
692
+ #artifactTruncationNoticeWritten = false;
671
693
 
672
694
  // Per-line column cap streaming state (persists across `push` calls so a
673
695
  // long line split across chunks still trips the same trigger).
@@ -697,6 +719,9 @@ export class OutputSink {
697
719
  readonly #onRawChunk?: (chunk: string) => void;
698
720
  readonly #chunkThrottleMs: number;
699
721
  readonly #maxColumns: number;
722
+ readonly #artifactMaxBytes: number;
723
+ readonly #coalesceSanitize: boolean;
724
+ #coalesceBuf = "";
700
725
 
701
726
  constructor(options?: OutputSinkOptions) {
702
727
  const {
@@ -708,6 +733,8 @@ export class OutputSink {
708
733
  onChunk,
709
734
  chunkThrottleMs = 0,
710
735
  onRawChunk,
736
+ artifactMaxBytes = DEFAULT_ARTIFACT_MAX_BYTES,
737
+ coalesceSanitize = process.env.PI_OUTPUT_SANITIZE_COALESCE === "1",
711
738
  } = options ?? {};
712
739
  this.#artifactPath = artifactPath;
713
740
  this.#artifactId = artifactId;
@@ -717,6 +744,8 @@ export class OutputSink {
717
744
  this.#onChunk = onChunk;
718
745
  this.#onRawChunk = onRawChunk;
719
746
  this.#chunkThrottleMs = chunkThrottleMs;
747
+ this.#artifactMaxBytes = Math.max(0, artifactMaxBytes);
748
+ this.#coalesceSanitize = coalesceSanitize;
720
749
  }
721
750
 
722
751
  #headText(): string {
@@ -754,7 +783,28 @@ export class OutputSink {
754
783
  * visible retention windows are selected from the sanitized/column-capped
755
784
  * stream so production-default display matches the historical processed view.
756
785
  */
786
+ // F21: with coalescing enabled, accumulate raw chunks and process them in batches; the default
787
+ // (disabled) path calls #ingest directly and is byte-identical to the historical per-chunk path.
757
788
  push(chunk: string): void {
789
+ if (!this.#coalesceSanitize) {
790
+ this.#ingest(chunk);
791
+ return;
792
+ }
793
+ this.#coalesceBuf += chunk;
794
+ if (this.#coalesceBuf.length >= COALESCE_FLUSH_CHARS) {
795
+ this.#flushCoalesced();
796
+ }
797
+ }
798
+
799
+ /** Process any buffered coalesced chunks as a single batch (F21). */
800
+ #flushCoalesced(): void {
801
+ if (this.#coalesceBuf.length === 0) return;
802
+ const batch = this.#coalesceBuf;
803
+ this.#coalesceBuf = "";
804
+ this.#ingest(batch);
805
+ }
806
+
807
+ #ingest(chunk: string): void {
758
808
  const rawChunk = chunk;
759
809
 
760
810
  // Live callbacks historically observe sanitized, uncapped chunks. The same
@@ -907,6 +957,40 @@ export class OutputSink {
907
957
  }
908
958
  }
909
959
 
960
+ #artifactTruncationNotice(droppedBytes: number): string {
961
+ return `\n[artifact truncated after ${this.#artifactBytes} bytes; omitted at least ${droppedBytes} bytes]\n`;
962
+ }
963
+
964
+ #capArtifactChunk(chunk: string, bytes: number): { chunk: string; bytes: number } | null {
965
+ if (bytes === 0) return null;
966
+ if (this.#artifactMaxBytes <= 0 || this.#artifactBytes >= this.#artifactMaxBytes) {
967
+ this.#artifactTruncatedBytes += bytes;
968
+ return null;
969
+ }
970
+ const room = this.#artifactMaxBytes - this.#artifactBytes;
971
+ if (bytes <= room) {
972
+ return { chunk, bytes };
973
+ }
974
+ const kept = truncateHeadBytes(chunk, room);
975
+ this.#artifactTruncatedBytes += bytes - kept.bytes;
976
+ return kept.bytes > 0 ? { chunk: kept.text, bytes: kept.bytes } : null;
977
+ }
978
+
979
+ #writeArtifactTruncationNotice(): void {
980
+ if (this.#artifactTruncatedBytes <= 0 || this.#artifactTruncationNoticeWritten) return;
981
+ const notice = this.#artifactTruncationNotice(this.#artifactTruncatedBytes);
982
+ try {
983
+ if (this.#fileReady && this.#file) {
984
+ this.#file.sink.write(notice);
985
+ } else {
986
+ this.#queuePendingFileWrite(notice, Buffer.byteLength(notice, "utf-8"));
987
+ }
988
+ this.#artifactTruncationNoticeWritten = true;
989
+ } catch {
990
+ /* ignore */
991
+ }
992
+ }
993
+
910
994
  #queuePendingFileWrite(chunk: string, bytes = Buffer.byteLength(chunk, "utf-8")): void {
911
995
  if (!this.#pendingFileWrites) this.#pendingFileWrites = [chunk];
912
996
  else this.#pendingFileWrites.push(chunk);
@@ -915,14 +999,17 @@ export class OutputSink {
915
999
  }
916
1000
 
917
1001
  #enqueueFileWrite(chunk: string, bytes: number): void {
1002
+ const capped = this.#capArtifactChunk(chunk, bytes);
1003
+ if (!capped) return;
1004
+ this.#artifactBytes += capped.bytes;
918
1005
  if (!this.#fileReady || !this.#file) {
919
- this.#queuePendingFileWrite(chunk, bytes);
1006
+ this.#queuePendingFileWrite(capped.chunk, capped.bytes);
920
1007
  if (this.#willOverflow(bytes) || this.#pendingFileWriteBytes > this.#spillThreshold) this.#createFileSink();
921
1008
  return;
922
1009
  }
923
1010
 
924
1011
  try {
925
- this.#file.sink.write(chunk);
1012
+ this.#file.sink.write(capped.chunk);
926
1013
  } catch {
927
1014
  try {
928
1015
  void this.#file.sink.end();
@@ -931,7 +1018,7 @@ export class OutputSink {
931
1018
  }
932
1019
  this.#file = undefined;
933
1020
  this.#fileReady = false;
934
- this.#queuePendingFileWrite(chunk, bytes);
1021
+ this.#queuePendingFileWrite(capped.chunk, capped.bytes);
935
1022
  this.#createFileSink();
936
1023
  }
937
1024
  }
@@ -998,6 +1085,7 @@ export class OutputSink {
998
1085
  * branch in `dump()` against stale totals.
999
1086
  */
1000
1087
  replace(text: string): void {
1088
+ this.#coalesceBuf = "";
1001
1089
  this.#setTail(text);
1002
1090
  this.#head = "";
1003
1091
  this.#headBytes = 0;
@@ -1015,10 +1103,13 @@ export class OutputSink {
1015
1103
  }
1016
1104
 
1017
1105
  async dump(notice?: string): Promise<OutputSummary> {
1106
+ this.#flushCoalesced();
1018
1107
  const noticeLine = notice ? `[${notice}]\n` : "";
1019
1108
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
1020
1109
 
1021
1110
  let artifactId: string | undefined;
1111
+ if (this.#artifactTruncatedBytes > 0) this.#createFileSink();
1112
+ this.#writeArtifactTruncationNotice();
1022
1113
  if (this.#file) {
1023
1114
  artifactId = this.#file.artifactId;
1024
1115
  await this.#file.sink.end();
@@ -1095,6 +1186,7 @@ export class OutputSink {
1095
1186
  elidedLines,
1096
1187
  columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
1097
1188
  columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
1189
+ artifactTruncatedBytes: this.#artifactTruncatedBytes > 0 ? this.#artifactTruncatedBytes : undefined,
1098
1190
  artifactId,
1099
1191
  };
1100
1192
  }
@@ -1,3 +1,5 @@
1
+ import { formatProviderCredentialHint } from "@gajae-code/ai/stream";
2
+
1
3
  export const MODEL_ONBOARDING_API_PROVIDER_COMMAND =
2
4
  "/provider add --compat <openai|anthropic> --provider <id> --base-url <url> --api-key-env <ENV> --model <model>";
3
5
  export const MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND = "/provider add --preset <minimax|minimax-cn|glm>";
@@ -26,14 +28,19 @@ export function formatNoModelOnboardingError(): string {
26
28
  }
27
29
 
28
30
  export function formatNoCredentialOnboardingError(providerId: string): string {
29
- return [
31
+ const lines = [
30
32
  `No credentials found for ${providerId}.`,
31
33
  "",
32
34
  `For MiniMax/GLM presets, configure credentials with ${MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND} (or ${MODEL_ONBOARDING_SETUP_COMMAND} --preset <preset>).`,
33
35
  `For custom API-compatible providers, use ${MODEL_ONBOARDING_API_PROVIDER_COMMAND}.`,
34
- `For OAuth/subscription providers, use ${MODEL_ONBOARDING_OAUTH_COMMAND}.`,
36
+ `For OAuth/subscription providers, use ${MODEL_ONBOARDING_OAUTH_COMMAND} (interactive; not available in headless/print mode).`,
37
+ ];
38
+ const headlessHint = formatProviderCredentialHint(providerId);
39
+ if (headlessHint) lines.push(headlessHint);
40
+ lines.push(
35
41
  "Then run /model to select a configured model or assign it to DEFAULT, EXECUTOR, ARCHITECT, PLANNER, or CRITIC.",
36
- ].join("\n");
42
+ );
43
+ return lines.join("\n");
37
44
  }
38
45
 
39
46
  export function formatNoModelsAvailableFallback(): string {
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import {
3
3
  type ActiveSessionScope,
4
+ readActiveEntries,
4
5
  rebuildActiveSnapshot,
5
6
  removeActiveEntry,
6
7
  writeActiveEntry,
@@ -416,6 +417,62 @@ function rawActiveEntries(state: SkillActiveState | null): SkillActiveEntry[] {
416
417
  return out;
417
418
  }
418
419
 
420
+ async function readModeStatePhase(
421
+ cwd: string,
422
+ sessionId: string | undefined,
423
+ skill: CanonicalGjcWorkflowSkill,
424
+ ): Promise<string | undefined> {
425
+ const stateDir = path.join(cwd, ".gjc", "state");
426
+ const normalizedSessionId = safeString(sessionId).trim();
427
+ const filePath = normalizedSessionId
428
+ ? path.join(stateDir, "sessions", encodePathSegment(normalizedSessionId), `${skill}-state.json`)
429
+ : path.join(stateDir, `${skill}-state.json`);
430
+ try {
431
+ const parsed = JSON.parse(await Bun.file(filePath).text());
432
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
433
+ const record = parsed as Record<string, unknown>;
434
+ const phase = safeString(record.current_phase).trim();
435
+ if (!phase) return undefined;
436
+ if (record.active === false && !RALPLAN_CANONICAL_PHASE_OVERRIDES.has(phase)) return undefined;
437
+ return phase;
438
+ } catch {
439
+ return undefined;
440
+ }
441
+ }
442
+
443
+ const RALPLAN_CANONICAL_PHASE_OVERRIDES = new Set([
444
+ "final",
445
+ "handoff",
446
+ "complete",
447
+ "completed",
448
+ "failed",
449
+ "cancelled",
450
+ "canceled",
451
+ "inactive",
452
+ ]);
453
+
454
+ function withCanonicalRalplanPhase(entry: SkillActiveEntry, canonicalPhase: string | undefined): SkillActiveEntry {
455
+ if (
456
+ entry.skill !== "ralplan" ||
457
+ !canonicalPhase ||
458
+ !RALPLAN_CANONICAL_PHASE_OVERRIDES.has(canonicalPhase) ||
459
+ entry.phase === canonicalPhase
460
+ ) {
461
+ return entry;
462
+ }
463
+ const hud = entry.hud
464
+ ? {
465
+ ...entry.hud,
466
+ chips: entry.hud.chips?.map(chip => (chip.label === "stage" ? { ...chip, value: canonicalPhase } : chip)),
467
+ }
468
+ : undefined;
469
+ return {
470
+ ...entry,
471
+ phase: canonicalPhase,
472
+ ...(hud ? { hud } : {}),
473
+ };
474
+ }
475
+
419
476
  function filterRootEntriesForSession(entries: SkillActiveEntry[], sessionId?: string): SkillActiveEntry[] {
420
477
  const normalizedSessionId = safeString(sessionId).trim();
421
478
  if (!normalizedSessionId) return entries;
@@ -538,19 +595,32 @@ export function collapsePlanningPipeline(entries: readonly SkillActiveEntry[]):
538
595
  return entries.filter(entry => !PLANNING_PIPELINE_SKILLS.has(entry.skill) || entry === current);
539
596
  }
540
597
 
541
- function mergeVisibleEntries(
598
+ async function mergeVisibleEntries(
599
+ cwd: string,
542
600
  sessionState: SkillActiveState | null,
543
601
  rootState: SkillActiveState | null,
544
602
  sessionId?: string,
545
- ): SkillActiveEntry[] {
603
+ ): Promise<SkillActiveEntry[]> {
546
604
  // Use the raw (active + inactive) rows so a handoff demotion stays visible
547
605
  // long enough to supersede a stale same-skill row before the active filter.
548
- const rootEntries = filterRootEntriesForSession(rawActiveEntries(rootState), sessionId);
606
+ // Per-skill files in active/<skill>.json are authoritative and are merged
607
+ // after the derived snapshot cache, so a stale skill-active-state.json row
608
+ // cannot override the latest entry file.
609
+ const rootEntries = filterRootEntriesForSession(
610
+ [...rawActiveEntries(rootState), ...(await readActiveEntries(cwd))],
611
+ sessionId,
612
+ );
549
613
  const merged = new Map(rootEntries.map(entry => [entryKey(entry), entry]));
550
- for (const entry of rawActiveEntries(sessionState)) {
614
+ const sessionEntries = sessionId
615
+ ? [...rawActiveEntries(sessionState), ...(await readActiveEntries(cwd, { sessionId }))]
616
+ : rawActiveEntries(sessionState);
617
+ for (const entry of sessionEntries) {
551
618
  merged.set(entryKey(entry), entry);
552
619
  }
553
- return dedupeVisibleBySkill([...merged.values()], sessionId).filter(entry => entry.active !== false);
620
+ const canonicalRalplanPhase = await readModeStatePhase(cwd, sessionId, "ralplan");
621
+ return dedupeVisibleBySkill([...merged.values()], sessionId)
622
+ .filter(entry => entry.active !== false)
623
+ .map(entry => withCanonicalRalplanPhase(entry, canonicalRalplanPhase));
554
624
  }
555
625
 
556
626
  export async function readVisibleSkillActiveState(cwd: string, sessionId?: string): Promise<SkillActiveState | null> {
@@ -559,7 +629,7 @@ export async function readVisibleSkillActiveState(cwd: string, sessionId?: strin
559
629
  readRawActiveStateForHandoff(rootPath, false),
560
630
  sessionPath ? readRawActiveStateForHandoff(sessionPath, false) : Promise.resolve(null),
561
631
  ]);
562
- const activeSkills = mergeVisibleEntries(sessionState, rootState, sessionId);
632
+ const activeSkills = await mergeVisibleEntries(cwd, sessionState, rootState, sessionId);
563
633
  if (activeSkills.length === 0) return null;
564
634
  const primary = activeSkills[0];
565
635
  return {
@@ -622,7 +692,9 @@ async function activeSubskillsForExistingEntry(
622
692
  readRawActiveStateForHandoff(rootPath, false),
623
693
  sessionPath ? readRawActiveStateForHandoff(sessionPath, false) : Promise.resolve(null),
624
694
  ]);
625
- const existing = mergeVisibleEntries(sessionState, rootState, sessionId).find(entry => entry.skill === skill);
695
+ const existing = (await mergeVisibleEntries(cwd, sessionState, rootState, sessionId)).find(
696
+ entry => entry.skill === skill,
697
+ );
626
698
  return existing?.active_subskills;
627
699
  }
628
700
 
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import type { ThinkingLevel } from "@gajae-code/agent-core";
4
4
  import { type Model, modelsAreEqual } from "@gajae-code/ai";
5
5
  import { getOAuthProviders } from "@gajae-code/ai/utils/oauth";
6
+ import { Spacer, Text } from "@gajae-code/tui";
6
7
  import { setProjectDir } from "@gajae-code/utils";
7
8
  import { jobElapsedMs } from "../async";
8
9
  import {
@@ -13,6 +14,8 @@ import {
13
14
  import { extractExplicitThinkingSelector, formatModelSelectorValue, parseModelPattern } from "../config/model-resolver";
14
15
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers.js";
15
16
  import { resolveMemoryBackend } from "../memory-backend";
17
+ import { DynamicBorder } from "../modes/components/dynamic-border";
18
+ import { theme } from "../modes/theme/theme";
16
19
  import type { InteractiveModeContext } from "../modes/types";
17
20
  import { formatModelOnboardingGuidance } from "../setup/model-onboarding-guidance";
18
21
  import {
@@ -23,6 +26,7 @@ import {
23
26
  } from "../setup/provider-onboarding";
24
27
  import { parseThinkingLevel } from "../thinking";
25
28
  import { buildContextReportText } from "./helpers/context-report";
29
+ import { buildFastStatusReport } from "./helpers/fast-status-report";
26
30
  import { formatDuration } from "./helpers/format";
27
31
  import { commandConsumed, errorMessage, parseSlashCommand, usage } from "./helpers/parse";
28
32
  import { handleSshAcp } from "./helpers/ssh";
@@ -41,6 +45,14 @@ export type { BuiltinSlashCommand, SubcommandDef } from "./types";
41
45
  /** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */
42
46
  export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
43
47
 
48
+ function fastStatusRoleTargets(): Array<{ id: GjcModelAssignmentTargetId; label: string; isSubagentRole: boolean }> {
49
+ return GJC_MODEL_ASSIGNMENT_TARGET_IDS.map(id => ({
50
+ id,
51
+ label: GJC_MODEL_ASSIGNMENT_TARGETS[id].tag ?? id.toUpperCase(),
52
+ isSubagentRole: GJC_MODEL_ASSIGNMENT_TARGETS[id].settingsPath === "task.agentModelOverrides",
53
+ }));
54
+ }
55
+
44
56
  function parseProviderSetupSlashArgs(args: string): {
45
57
  preset?: string;
46
58
  compat?: string;
@@ -357,7 +369,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
357
369
  return commandConsumed();
358
370
  }
359
371
  if (arg === "status") {
360
- await runtime.output(`Fast mode is ${runtime.session.isFastModeEnabled() ? "on" : "off"}.`);
372
+ await runtime.output(
373
+ buildFastStatusReport({
374
+ session: runtime.session,
375
+ roleTargets: fastStatusRoleTargets(),
376
+ iconFast: theme.icon.fast,
377
+ }),
378
+ );
361
379
  return commandConsumed();
362
380
  }
363
381
  return usage("Usage: /fast [on|off|status]", runtime);
@@ -386,8 +404,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
386
404
  return;
387
405
  }
388
406
  if (arg === "status") {
389
- const enabled = runtime.ctx.session.isFastModeEnabled();
390
- runtime.ctx.showStatus(`Fast mode is ${enabled ? "on" : "off"}.`);
407
+ const report = buildFastStatusReport({
408
+ session: runtime.ctx.session,
409
+ roleTargets: fastStatusRoleTargets(),
410
+ iconFast: theme.icon.fast,
411
+ formatInactive: text => theme.fg("dim", text),
412
+ });
413
+ runtime.ctx.chatContainer.addChild(new Spacer(1));
414
+ runtime.ctx.chatContainer.addChild(new DynamicBorder());
415
+ runtime.ctx.chatContainer.addChild(new Text(report, 1, 0));
416
+ runtime.ctx.chatContainer.addChild(new DynamicBorder());
417
+ runtime.ctx.ui.requestRender();
391
418
  runtime.ctx.editor.setText("");
392
419
  return;
393
420
  }
@@ -0,0 +1,111 @@
1
+ import type { Model } from "@gajae-code/ai";
2
+
3
+ /**
4
+ * A single line in the `/fast status` report: a labelled model and whether fast
5
+ * mode is effective for it. The `fast` flag is resolved by the caller
6
+ * (`buildFastStatusReport`) so each row can use the correct service tier — the
7
+ * main session tier for the current model / `modelRoles` roles, or the subagent
8
+ * tier (`task.serviceTier`) for `task.agentModelOverrides` roles.
9
+ */
10
+ export interface FastStatusRow {
11
+ /** Display label, e.g. "현재 모델", "DEFAULT", "EXECUTOR". */
12
+ label: string;
13
+ /** Resolved model for this row, if any. */
14
+ model?: Model;
15
+ /** Whether fast mode is effective for this row's model. */
16
+ fast: boolean;
17
+ }
18
+
19
+ export interface FormatFastStatusReportArgs {
20
+ rows: FastStatusRow[];
21
+ /** The active theme's fast icon token (`theme.icon.fast`). */
22
+ iconFast: string;
23
+ /** Optional decorator for inactive ("off") text, e.g. theme dim in the TUI. */
24
+ formatInactive?: (text: string) => string;
25
+ }
26
+
27
+ /** Title line of the `/fast status` report. */
28
+ export const FAST_STATUS_TITLE = "Fast 모드 상태";
29
+
30
+ /** The inactive marker shown for rows where fast mode does not apply. */
31
+ export const FAST_STATUS_OFF = "off";
32
+
33
+ /**
34
+ * Format a multiline `/fast status` report. Pure and shared by the CLI
35
+ * (`handle`) and TUI (`handleTui`) command branches so the two never drift.
36
+ * Each row's fast/off state is decided by the caller (see
37
+ * {@link buildFastStatusReport}) so per-row service-tier differences are honored.
38
+ */
39
+ export function formatFastStatusReport(args: FormatFastStatusReportArgs): string {
40
+ const { rows, iconFast } = args;
41
+ const formatInactive = args.formatInactive ?? ((text: string) => text);
42
+ const lines: string[] = [FAST_STATUS_TITLE];
43
+ for (const row of rows) {
44
+ if (!row.model) {
45
+ lines.push(`${row.label}: ${formatInactive(FAST_STATUS_OFF)}`);
46
+ continue;
47
+ }
48
+ const ref = `${row.model.provider}/${row.model.id}`;
49
+ lines.push(`${row.label}: ${ref} ${row.fast ? iconFast : formatInactive(FAST_STATUS_OFF)}`);
50
+ }
51
+ return lines.join("\n");
52
+ }
53
+
54
+ /** Minimal session surface needed to build the `/fast status` report. */
55
+ export interface FastStatusSessionLike {
56
+ readonly model?: Model;
57
+ /** Fast predicate against the main session tier (current model + `modelRoles`). */
58
+ isFastForProvider(provider?: string): boolean;
59
+ /** Fast predicate against the effective subagent tier (`task.agentModelOverrides` roles). */
60
+ isFastForSubagentProvider(provider?: string): boolean;
61
+ resolveRoleModelWithThinking(role: string): { model?: Model };
62
+ }
63
+
64
+ /** A role to enumerate in the report, with the tier source its subagent runs under. */
65
+ export interface FastStatusRoleTarget {
66
+ id: string;
67
+ label: string;
68
+ /**
69
+ * True for `task.agentModelOverrides` roles (executor/architect/planner/critic)
70
+ * that run under `task.serviceTier`; false for `modelRoles` roles (default)
71
+ * that run under the main session tier.
72
+ */
73
+ isSubagentRole: boolean;
74
+ }
75
+
76
+ export interface BuildFastStatusReportArgs {
77
+ session: FastStatusSessionLike;
78
+ /** Role targets to enumerate, in display order. */
79
+ roleTargets: ReadonlyArray<FastStatusRoleTarget>;
80
+ /** The active theme's fast icon token (`theme.icon.fast`). */
81
+ iconFast: string;
82
+ /** Optional decorator for inactive ("off") text, e.g. theme dim in the TUI. */
83
+ formatInactive?: (text: string) => string;
84
+ }
85
+
86
+ /**
87
+ * Build the `/fast status` report from a live session: the active/current model
88
+ * followed by each assigned role (subagent) model. Unassigned roles are skipped
89
+ * so the report mirrors the `/model` selector, which only badges assigned roles.
90
+ *
91
+ * Subagent roles (`task.agentModelOverrides`) are evaluated against the
92
+ * effective subagent tier (`task.serviceTier`), while the current model and
93
+ * `modelRoles` roles use the main session tier — matching where each model
94
+ * actually runs.
95
+ */
96
+ export function buildFastStatusReport(args: BuildFastStatusReportArgs): string {
97
+ const { session, roleTargets, iconFast, formatInactive } = args;
98
+ const rows: FastStatusRow[] = [
99
+ { label: "현재 모델", model: session.model, fast: session.isFastForProvider(session.model?.provider) },
100
+ ];
101
+ for (const target of roleTargets) {
102
+ const resolved = session.resolveRoleModelWithThinking(target.id);
103
+ if (resolved.model) {
104
+ const fast = target.isSubagentRole
105
+ ? session.isFastForSubagentProvider(resolved.model.provider)
106
+ : session.isFastForProvider(resolved.model.provider);
107
+ rows.push({ label: target.label, model: resolved.model, fast });
108
+ }
109
+ }
110
+ return formatFastStatusReport({ rows, iconFast, formatInactive });
111
+ }
@@ -315,7 +315,16 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
315
315
  throw new ToolError(`Unsupported archive format: ${filePath}`);
316
316
  }
317
317
 
318
- const bytes = await Bun.file(filePath).bytes();
318
+ // F20: cap the compressed archive read so opening a multi-GB archive cannot buffer it
319
+ // whole into memory. (Zip-bomb expanded-size bounding would need a streaming inflate.)
320
+ const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
321
+ const file = Bun.file(filePath);
322
+ if (file.size > MAX_ARCHIVE_BYTES) {
323
+ throw new ToolError(
324
+ `Archive too large to open: ${filePath} is ${file.size} bytes (limit ${MAX_ARCHIVE_BYTES}). Extract a subset with a dedicated tool.`,
325
+ );
326
+ }
327
+ const bytes = await file.bytes();
319
328
  const entries = format === "zip" ? await readZipEntries(bytes) : await readTarEntries(bytes);
320
329
  return new ArchiveReader(format, entries);
321
330
  }
package/src/tools/bash.ts CHANGED
@@ -37,8 +37,13 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
37
37
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
38
38
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
39
39
 
40
- async function saveBashOriginalArtifact(session: ToolSession, originalText: string): Promise<string | undefined> {
40
+ export async function saveBashOriginalArtifactForTests(
41
+ session: ToolSession,
42
+ originalText: string,
43
+ ): Promise<string | undefined> {
41
44
  try {
45
+ const manager = session.getArtifactManager?.();
46
+ if (manager) return await manager.save(originalText, "bash-original");
42
47
  const alloc = await session.allocateOutputArtifact?.("bash-original");
43
48
  if (!alloc?.path || !alloc.id) return undefined;
44
49
  await Bun.write(alloc.path, originalText);
@@ -375,6 +380,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
375
380
  env: options.resolvedEnv,
376
381
  artifactPath,
377
382
  artifactId,
383
+ oneShot: true,
378
384
  onChunk: chunk => {
379
385
  tailBuffer.append(chunk);
380
386
  latestText = tailBuffer.text();
@@ -387,7 +393,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
387
393
  // path above.
388
394
  manager.appendOutput(jobId, chunk);
389
395
  },
390
- onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
396
+ onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
391
397
  });
392
398
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
393
399
  requestedTimeoutSec: options.requestedTimeoutSec,
@@ -675,6 +681,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
675
681
  env: prepared.resolvedEnv,
676
682
  artifactPath,
677
683
  artifactId,
684
+ oneShot: true,
678
685
  onChunk: chunk => {
679
686
  tailBuffer.append(chunk);
680
687
  void reportProgress(tailBuffer.text(), {
@@ -688,7 +695,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
688
695
  cursorOffset = slice.nextOffset;
689
696
  dispatchLines(slice.text);
690
697
  },
691
- onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
698
+ onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
692
699
  });
693
700
  flushTrailingLine();
694
701
  this.#buildResultText(result, prepared.timeoutSec, result.output || "(no output)");
@@ -996,7 +1003,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
996
1003
  artifactPath,
997
1004
  artifactId,
998
1005
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
999
- onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
1006
+ onMinimizedSave: originalText => saveBashOriginalArtifactForTests(this.session, originalText),
1000
1007
  });
1001
1008
  if (result.cancelled) {
1002
1009
  if (signal?.aborted) {
@@ -26,6 +26,13 @@ export interface BrowserHandle {
26
26
 
27
27
  const browsers = new Map<string, BrowserHandle>();
28
28
 
29
+ /**
30
+ * Upper bound on the CDP `browser.close()` round-trip during a forced (signal-path)
31
+ * teardown before we fall back to killing the Chrome process tree. Only applies when
32
+ * `kill` is set; graceful release still awaits close() unbounded.
33
+ */
34
+ const HEADLESS_FORCE_CLOSE_GRACE_MS = 1_500;
35
+
29
36
  function browserKey(kind: BrowserKind): string {
30
37
  switch (kind.kind) {
31
38
  case "headless":
@@ -164,13 +171,22 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
164
171
 
165
172
  async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
166
173
  if (handle.kind.kind === "headless") {
174
+ // Capture the launched Chrome process before close() so a forced (signal-path)
175
+ // teardown can SIGTERM/SIGKILL the tree even if the CDP close hangs on a wedged
176
+ // renderer. Otherwise the headless Chrome reparents to PID 1 (#698).
177
+ const proc = handle.browser.process();
167
178
  if (handle.browser.connected) {
168
179
  try {
169
- await handle.browser.close();
180
+ const closing = handle.browser.close();
181
+ // Graceful release waits for close() to finish (it also removes the
182
+ // puppeteer_dev_chrome_profile-* temp dir). Forced release bounds it so
183
+ // the kill fallback below still runs within the signal handler's budget.
184
+ await (opts.kill ? Promise.race([closing, Bun.sleep(HEADLESS_FORCE_CLOSE_GRACE_MS)]) : closing);
170
185
  } catch (err) {
171
186
  logger.debug("Failed to close headless browser", { error: (err as Error).message });
172
187
  }
173
188
  }
189
+ if (opts.kill && proc?.pid !== undefined) await gracefulKillTreeOnce(proc.pid);
174
190
  return;
175
191
  }
176
192
  if (handle.kind.kind === "connected") {