@gajae-code/coding-agent 0.5.2 → 0.5.3

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 (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/dap/client.d.ts +2 -1
  4. package/dist/types/edit/read-file.d.ts +6 -0
  5. package/dist/types/eval/js/context-manager.d.ts +3 -0
  6. package/dist/types/eval/js/executor.d.ts +1 -0
  7. package/dist/types/exec/bash-executor.d.ts +2 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  11. package/dist/types/modes/components/model-selector.d.ts +2 -0
  12. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  13. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  15. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  16. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  17. package/dist/types/runtime-mcp/types.d.ts +2 -0
  18. package/dist/types/session/agent-session.d.ts +17 -1
  19. package/dist/types/session/artifacts.d.ts +4 -1
  20. package/dist/types/session/streaming-output.d.ts +5 -0
  21. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  22. package/dist/types/tools/bash.d.ts +1 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  24. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  25. package/package.json +7 -7
  26. package/src/async/job-manager.ts +153 -39
  27. package/src/config/file-lock.ts +9 -1
  28. package/src/dap/client.ts +105 -64
  29. package/src/dap/session.ts +44 -7
  30. package/src/edit/read-file.ts +19 -1
  31. package/src/eval/js/context-manager.ts +228 -65
  32. package/src/eval/js/executor.ts +2 -0
  33. package/src/eval/js/index.ts +1 -0
  34. package/src/eval/js/worker-core.ts +10 -6
  35. package/src/eval/py/executor.ts +68 -19
  36. package/src/eval/py/kernel.ts +46 -22
  37. package/src/eval/py/runner.py +68 -14
  38. package/src/exec/bash-executor.ts +49 -13
  39. package/src/gjc-runtime/tmux-gc.ts +86 -37
  40. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  41. package/src/internal-urls/artifact-protocol.ts +10 -1
  42. package/src/internal-urls/docs-index.generated.ts +2 -2
  43. package/src/lsp/client.ts +64 -26
  44. package/src/lsp/index.ts +2 -1
  45. package/src/lsp/lspmux.ts +33 -9
  46. package/src/lsp/types.ts +2 -0
  47. package/src/modes/bridge/bridge-mode.ts +21 -0
  48. package/src/modes/components/assistant-message.ts +10 -2
  49. package/src/modes/components/bash-execution.ts +5 -1
  50. package/src/modes/components/eval-execution.ts +5 -1
  51. package/src/modes/components/model-selector.ts +34 -2
  52. package/src/modes/components/oauth-selector.ts +5 -0
  53. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  54. package/src/modes/components/skill-message.ts +24 -16
  55. package/src/modes/components/tool-execution.ts +6 -0
  56. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  57. package/src/modes/controllers/input-controller.ts +5 -0
  58. package/src/modes/controllers/selector-controller.ts +6 -1
  59. package/src/modes/utils/ui-helpers.ts +5 -2
  60. package/src/runtime/process-lifecycle.ts +400 -0
  61. package/src/runtime-mcp/manager.ts +164 -50
  62. package/src/runtime-mcp/transports/http.ts +12 -11
  63. package/src/runtime-mcp/transports/stdio.ts +64 -38
  64. package/src/runtime-mcp/types.ts +3 -0
  65. package/src/sdk.ts +27 -0
  66. package/src/session/agent-session.ts +168 -22
  67. package/src/session/artifacts.ts +17 -2
  68. package/src/session/blob-store.ts +36 -2
  69. package/src/session/session-manager.ts +29 -13
  70. package/src/session/streaming-output.ts +54 -3
  71. package/src/slash-commands/builtin-registry.ts +30 -3
  72. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  73. package/src/tools/archive-reader.ts +10 -1
  74. package/src/tools/bash.ts +11 -4
  75. package/src/tools/browser/tab-supervisor.ts +22 -0
  76. package/src/tools/browser.ts +38 -4
  77. package/src/tools/read.ts +11 -12
  78. package/src/tools/sqlite-reader.ts +19 -5
package/src/lsp/client.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { isEnoent, logger, ptree, untilAborted } from "@gajae-code/utils";
1
+ import { isEnoent, logger, untilAborted } from "@gajae-code/utils";
2
2
  import { formatCrashDiagnosticNotice, writeCrashReport } from "../debug/crash-diagnostics";
3
+ import { registerResourceOwner, spawnOwnedProcess } from "../runtime/process-lifecycle";
3
4
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
4
5
  import { applyWorkspaceEdit } from "./edits";
5
6
  import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
@@ -19,8 +20,10 @@ import { detectLanguageId, fileToUri } from "./utils";
19
20
  // =============================================================================
20
21
 
21
22
  const clients = new Map<string, LspClient>();
23
+ const killedClients = new WeakSet<LspClient>();
22
24
  const clientLocks = new Map<string, Promise<LspClient>>();
23
25
  const fileOperationLocks = new Map<string, Promise<void>>();
26
+ const lspCleanupOwner = registerResourceOwner("lsp:clients", shutdownAll);
24
27
 
25
28
  // Idle timeout configuration (disabled by default)
26
29
  let idleTimeoutMs: number | null = null;
@@ -65,6 +68,32 @@ export function isIdleCheckerActiveForTests(): boolean {
65
68
  return idleCheckInterval !== null;
66
69
  }
67
70
 
71
+ function rejectPendingRequests(client: LspClient, error: Error): void {
72
+ for (const pending of client.pendingRequests.values()) {
73
+ pending.reject(error);
74
+ }
75
+ client.pendingRequests.clear();
76
+ }
77
+
78
+ function deleteCachedClient(key: string, client: LspClient): void {
79
+ if (clients.get(key) === client) {
80
+ clients.delete(key);
81
+ }
82
+ }
83
+
84
+ function deleteClientLock(key: string, clientPromise: Promise<LspClient>): void {
85
+ if (clientLocks.get(key) === clientPromise) {
86
+ clientLocks.delete(key);
87
+ }
88
+ }
89
+
90
+ function evictDeadCachedClient(key: string, client: LspClient): void {
91
+ if (client.proc.exitCode === null && !client.proc.killed && !client.owner?.disposed && !killedClients.has(client))
92
+ return;
93
+ deleteCachedClient(key, client);
94
+ client.resolveProjectLoaded();
95
+ rejectPendingRequests(client, new Error("LSP server exited"));
96
+ }
68
97
  // =============================================================================
69
98
  // Client Capabilities
70
99
  // =============================================================================
@@ -432,8 +461,11 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
432
461
  // Check if client already exists
433
462
  const existingClient = clients.get(key);
434
463
  if (existingClient) {
435
- existingClient.lastActivity = Date.now();
436
- return existingClient;
464
+ evictDeadCachedClient(key, existingClient);
465
+ if (clients.has(key)) {
466
+ existingClient.lastActivity = Date.now();
467
+ return existingClient;
468
+ }
437
469
  }
438
470
 
439
471
  // Check if another coroutine is already creating this client
@@ -443,7 +475,8 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
443
475
  }
444
476
 
445
477
  // Create new client with lock
446
- const clientPromise = (async () => {
478
+ let clientPromise!: Promise<LspClient>;
479
+ clientPromise = (async () => {
447
480
  const baseCommand = config.resolvedCommand ?? config.command;
448
481
  const baseArgs = config.args ?? [];
449
482
 
@@ -452,11 +485,13 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
452
485
  ? await getLspmuxCommand(baseCommand, baseArgs)
453
486
  : { command: baseCommand, args: baseArgs };
454
487
 
455
- const proc = ptree.spawn([command, ...args], {
488
+ const owner = spawnOwnedProcess([command, ...args], {
456
489
  cwd,
457
490
  stdin: "pipe",
458
491
  env: env ? { ...Bun.env, ...env } : undefined,
492
+ name: `lsp:${config.command}`,
459
493
  });
494
+ const proc = owner.child;
460
495
 
461
496
  let resolveProjectLoaded!: () => void;
462
497
  const projectLoaded = new Promise<void>(resolve => {
@@ -474,6 +509,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
474
509
  name: key,
475
510
  cwd,
476
511
  proc,
512
+ owner,
477
513
  config,
478
514
  requestId: 0,
479
515
  diagnostics: new Map(),
@@ -488,12 +524,17 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
488
524
  projectLoaded,
489
525
  resolveProjectLoaded,
490
526
  };
527
+ const originalKill = proc.kill.bind(proc);
528
+ proc.kill = (...args: Parameters<typeof proc.kill>) => {
529
+ killedClients.add(client);
530
+ return originalKill(...args);
531
+ };
491
532
  clients.set(key, client);
492
533
 
493
534
  // Register crash recovery - remove client on process exit
494
535
  proc.exited.then(async () => {
495
- clients.delete(key);
496
- clientLocks.delete(key);
536
+ deleteCachedClient(key, client);
537
+ deleteClientLock(key, clientPromise);
497
538
  client.resolveProjectLoaded();
498
539
 
499
540
  // Reject any pending requests — the server is gone, they will never complete.
@@ -564,12 +605,12 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
564
605
  return client;
565
606
  } catch (err) {
566
607
  // Clean up on initialization failure
567
- clients.delete(key);
568
- clientLocks.delete(key);
569
- proc.kill();
608
+ deleteCachedClient(key, client);
609
+ deleteClientLock(key, clientPromise);
610
+ await shutdownClientInstance(client);
570
611
  throw err;
571
612
  } finally {
572
- clientLocks.delete(key);
613
+ deleteClientLock(key, clientPromise);
573
614
  }
574
615
  })();
575
616
 
@@ -645,12 +686,7 @@ export async function ensureFileOpen(client: LspClient, filePath: string, signal
645
686
  */
646
687
  export async function waitForProjectLoaded(client: LspClient, signal?: AbortSignal): Promise<void> {
647
688
  if (signal?.aborted) return;
648
- await Promise.race([
649
- client.projectLoaded,
650
- ...(signal
651
- ? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))]
652
- : []),
653
- ]);
689
+ await untilAborted(signal, client.projectLoaded);
654
690
  }
655
691
 
656
692
  /**
@@ -793,16 +829,12 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
793
829
  */
794
830
  async function shutdownClientInstance(client: LspClient): Promise<void> {
795
831
  const err = new Error("LSP client shutdown");
796
- for (const pending of Array.from(client.pendingRequests.values())) {
797
- pending.reject(err);
798
- }
799
- client.pendingRequests.clear();
832
+ rejectPendingRequests(client, err);
800
833
 
801
- const timeout = Bun.sleep(5_000);
802
- const shutdown = sendRequest(client, "shutdown", null).catch(() => {});
803
- await Promise.race([shutdown, timeout]);
804
- client.proc.kill();
805
- await Promise.race([client.proc.exited.catch(() => {}), Bun.sleep(1_000)]);
834
+ const shutdown = sendRequest(client, "shutdown", null, undefined, 5_000).catch(() => {});
835
+ await Promise.race([shutdown, Bun.sleep(5_000)]);
836
+ await client.owner?.dispose();
837
+ await client.owner?.awaitExit({ timeoutMs: 1_000 });
806
838
  }
807
839
 
808
840
  export async function shutdownClient(key: string): Promise<void> {
@@ -959,6 +991,12 @@ if (typeof process !== "undefined") {
959
991
  process.on("beforeExit", () => {
960
992
  void shutdownAll();
961
993
  });
994
+ process.on("exit", () => {
995
+ lspCleanupOwner();
996
+ for (const client of clients.values()) {
997
+ client.proc.kill();
998
+ }
999
+ });
962
1000
  process.on("SIGINT", () => {
963
1001
  void (async () => {
964
1002
  await shutdownAll();
package/src/lsp/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  sendNotification,
20
20
  sendRequest,
21
21
  setIdleTimeout,
22
+ shutdownClient,
22
23
  syncContent,
23
24
  WARMUP_TIMEOUT_MS,
24
25
  waitForProjectLoaded,
@@ -400,7 +401,7 @@ async function reloadServer(client: LspClient, serverName: string, signal?: Abor
400
401
  }
401
402
  }
402
403
  if (output.startsWith("Restarted")) {
403
- client.proc.kill();
404
+ await shutdownClient(client.name);
404
405
  }
405
406
  return output;
406
407
  }
package/src/lsp/lspmux.ts CHANGED
@@ -2,6 +2,7 @@ import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import { $flag, $which, logger } from "@gajae-code/utils";
4
4
  import { TOML } from "bun";
5
+ import { spawnOwnedProcess } from "../runtime/process-lifecycle";
5
6
 
6
7
  /**
7
8
  * lspmux integration for LSP server multiplexing.
@@ -101,19 +102,24 @@ async function parseConfig(): Promise<LspmuxConfig | null> {
101
102
  */
102
103
  async function checkServerRunning(binaryPath: string): Promise<boolean> {
103
104
  try {
104
- const proc = Bun.spawn([binaryPath, "status"], {
105
- stdout: "pipe",
106
- stderr: "pipe",
107
- windowsHide: true,
105
+ const owner = spawnOwnedProcess([binaryPath, "status"], {
106
+ stdin: "ignore",
107
+ name: "lspmux:status",
108
108
  });
109
+ const proc = owner.child;
110
+ drainStream(proc.stdout);
111
+ drainStream(proc.stderr);
109
112
 
110
- const exited = await Promise.race([
111
- proc.exited,
112
- new Promise<null>(resolve => setTimeout(() => resolve(null), LIVENESS_TIMEOUT_MS)),
113
- ]);
113
+ let timer: ReturnType<typeof setTimeout> | undefined;
114
+ const timeout = new Promise<null>(resolve => {
115
+ timer = setTimeout(() => resolve(null), LIVENESS_TIMEOUT_MS);
116
+ });
117
+ const exited = await Promise.race([proc.exited, timeout]);
118
+ if (timer) clearTimeout(timer);
114
119
 
115
120
  if (exited === null) {
116
- proc.kill();
121
+ await owner.dispose();
122
+ await owner.awaitExit({ timeoutMs: 1_000 });
117
123
  return false;
118
124
  }
119
125
 
@@ -123,6 +129,24 @@ async function checkServerRunning(binaryPath: string): Promise<boolean> {
123
129
  }
124
130
  }
125
131
 
132
+ function drainStream(stream: ReadableStream<Uint8Array> | null | undefined): void {
133
+ if (!stream) return;
134
+ void (async () => {
135
+ try {
136
+ const reader = stream.getReader();
137
+ try {
138
+ while (!(await reader.read()).done) {
139
+ // drain only
140
+ }
141
+ } finally {
142
+ reader.releaseLock();
143
+ }
144
+ } catch {
145
+ // Process stream closed or already consumed.
146
+ }
147
+ })();
148
+ }
149
+
126
150
  /**
127
151
  * Detect lspmux availability and state.
128
152
  * Results are cached for STATE_CACHE_TTL_MS.
package/src/lsp/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ptree } from "@gajae-code/utils";
2
2
  import * as z from "zod/v4";
3
+ import type { OwnedProcess } from "../runtime/process-lifecycle";
3
4
 
4
5
  // =============================================================================
5
6
  // Tool Schema
@@ -399,6 +400,7 @@ export interface LspClient {
399
400
  cwd: string;
400
401
  config: ServerConfig;
401
402
  proc: ptree.ChildProcess<"pipe">;
403
+ owner?: OwnedProcess;
402
404
  requestId: number;
403
405
  diagnostics: Map<string, PublishedDiagnostics>;
404
406
  diagnosticsVersion: number;
@@ -166,6 +166,25 @@ function parseBridgeScopes(value: string | undefined): readonly BridgeCommandSco
166
166
  return [...scopes];
167
167
  }
168
168
 
169
+ // Opt-in endpoint enablement via GJC_BRIDGE_ENDPOINTS (default undefined -> fail closed, backward compatible).
170
+ // Accepts "all" or a comma list of matrix keys.
171
+ export function parseBridgeEndpoints(value: string | undefined): Partial<BridgeEndpointMatrix> | undefined {
172
+ if (!value?.trim()) return undefined;
173
+ const allowed = new Set<string>(Object.keys(FAIL_CLOSED_BRIDGE_ENDPOINTS));
174
+ const matrix: Partial<BridgeEndpointMatrix> = {};
175
+ if (value.trim().toLowerCase() === "all") {
176
+ for (const key of allowed) matrix[key as keyof BridgeEndpointMatrix] = true;
177
+ return matrix;
178
+ }
179
+ for (const raw of value.split(",")) {
180
+ const key = raw.trim();
181
+ if (!key) continue;
182
+ if (!allowed.has(key)) throw new Error(`Invalid GJC_BRIDGE_ENDPOINTS entry: ${key}`);
183
+ matrix[key as keyof BridgeEndpointMatrix] = true;
184
+ }
185
+ return matrix;
186
+ }
187
+
169
188
  function hasScope(scopes: readonly BridgeCommandScope[] | undefined, scope: BridgeCommandScope): boolean {
170
189
  return new Set(scopes ?? DEFAULT_BRIDGE_SCOPES).has(scope);
171
190
  }
@@ -521,6 +540,7 @@ export async function runBridgeMode(
521
540
  throw new Error(`Invalid GJC_BRIDGE_PORT: ${Bun.env.GJC_BRIDGE_PORT}`);
522
541
  }
523
542
  const commandScopes = parseBridgeScopes(Bun.env.GJC_BRIDGE_SCOPES);
543
+ const endpointMatrix = parseBridgeEndpoints(Bun.env.GJC_BRIDGE_ENDPOINTS);
524
544
 
525
545
  const certPath = Bun.env.GJC_BRIDGE_TLS_CERT;
526
546
  const keyPath = Bun.env.GJC_BRIDGE_TLS_KEY;
@@ -626,6 +646,7 @@ export async function runBridgeMode(
626
646
  hostToolBridge,
627
647
  hostUriBridge,
628
648
  commandScopes,
649
+ endpointMatrix,
629
650
  unattendedControlPlane,
630
651
  commandDispatcher: command =>
631
652
  dispatchRpcCommand(command, {
@@ -111,8 +111,16 @@ export class AssistantMessageComponent extends Container {
111
111
  const cached = this.#contentBlocksCache.get(content);
112
112
  if (cached?.source === content.text) return cached.component;
113
113
  const trimmed = content.text.trim();
114
- const component =
115
- renderDeepInterviewAssistantText(trimmed, theme) ?? new Markdown(trimmed, 1, 0, getMarkdownTheme());
114
+ const deepInterview = renderDeepInterviewAssistantText(trimmed, theme);
115
+ // Reuse the same Markdown instance across streaming chunks (update text in place)
116
+ // instead of constructing a new one each chunk; combined with the markdown
117
+ // per-code-block highlight cache, appends no longer re-highlight the whole prefix.
118
+ if (!deepInterview && cached && cached.component instanceof Markdown) {
119
+ cached.component.setText(trimmed);
120
+ cached.source = content.text;
121
+ return cached.component;
122
+ }
123
+ const component = deepInterview ?? new Markdown(trimmed, 1, 0, getMarkdownTheme());
116
124
  this.#contentBlocksCache.set(content, { source: content.text, component });
117
125
  return component;
118
126
  }
@@ -155,7 +155,11 @@ export class BashExecutionComponent extends Container {
155
155
  const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
156
156
 
157
157
  // Rebuild content container
158
- this.#contentContainer.clear();
158
+ // Detach (not dispose): #headerText and the running #loader are persistent,
159
+ // reused instances re-added below. A disposing clear() would stop the loader's
160
+ // animation timer mid-run. Final teardown still stops the loader via the
161
+ // component's recursive dispose().
162
+ this.#contentContainer.detachAll();
159
163
 
160
164
  // Command header
161
165
  this.#contentContainer.addChild(this.#headerText);
@@ -114,7 +114,11 @@ export class EvalExecutionComponent extends Container {
114
114
  const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
115
115
  const hiddenLineCount = availableLines.length - previewLogicalLines.length;
116
116
 
117
- this.#contentContainer.clear();
117
+ // Detach (not dispose): #headerText and the running #loader are persistent,
118
+ // reused instances re-added below. A disposing clear() would stop the loader's
119
+ // animation timer mid-run. Final teardown still stops the loader via the
120
+ // component's recursive dispose().
121
+ this.#contentContainer.detachAll();
118
122
 
119
123
  this.#contentContainer.addChild(this.#headerText);
120
124
 
@@ -210,6 +210,9 @@ export class ModelSelectorComponent extends Container {
210
210
  #tui: TUI;
211
211
  #scopedModels: ReadonlyArray<ScopedModelItem>;
212
212
  #temporaryOnly: boolean;
213
+ #currentModel?: Model;
214
+ #isFastForProvider: (provider?: string) => boolean = () => false;
215
+ #isFastForSubagentProvider: (provider?: string) => boolean = () => false;
213
216
  #pendingActionItem?: ModelItem | CanonicalModelItem;
214
217
  #selectedActionIndex: number = 0;
215
218
  #pendingThinkingChoice?: PendingThinkingChoice;
@@ -239,7 +242,13 @@ export class ModelSelectorComponent extends Container {
239
242
  scopedModels: ReadonlyArray<ScopedModelItem>,
240
243
  onSelect: RoleSelectCallback,
241
244
  onCancel: () => void,
242
- options?: { temporaryOnly?: boolean; initialSearchInput?: string; sessionId?: string },
245
+ options?: {
246
+ temporaryOnly?: boolean;
247
+ initialSearchInput?: string;
248
+ sessionId?: string;
249
+ isFastForProvider?: (provider?: string) => boolean;
250
+ isFastForSubagentProvider?: (provider?: string) => boolean;
251
+ },
243
252
  ) {
244
253
  super();
245
254
 
@@ -251,6 +260,9 @@ export class ModelSelectorComponent extends Container {
251
260
  this.#onCancelCallback = onCancel;
252
261
  this.#temporaryOnly = options?.temporaryOnly ?? false;
253
262
  this.#authSessionId = options?.sessionId;
263
+ this.#currentModel = _currentModel;
264
+ this.#isFastForProvider = options?.isFastForProvider ?? (() => false);
265
+ this.#isFastForSubagentProvider = options?.isFastForSubagentProvider ?? (() => false);
254
266
  const initialSearchInput = options?.initialSearchInput;
255
267
  this.#viewMode = this.#temporaryOnly || initialSearchInput || scopedModels.length > 0 ? "models" : "presets";
256
268
 
@@ -928,15 +940,35 @@ export class ModelSelectorComponent extends Container {
928
940
 
929
941
  // Build role badges (inverted: color as background, black text)
930
942
  const roleBadgeTokens: string[] = [];
943
+ let roleMatched = false;
931
944
  for (const role of GJC_MODEL_ASSIGNMENT_TARGET_IDS) {
932
945
  const roleInfo = GJC_MODEL_ASSIGNMENT_TARGETS[role];
933
946
  const assigned = this.#roles[role];
934
947
  if (roleInfo.tag && assigned && modelsAreEqual(assigned.model, item.model)) {
948
+ roleMatched = true;
935
949
  const badge = makeInvertedBadge(roleInfo.tag, roleInfo.color ?? "muted");
936
950
  const thinkingLabel = getThinkingLevelMetadata(assigned.thinkingLevel).label;
937
- roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`);
951
+ // Subagent roles (task.agentModelOverrides) run under task.serviceTier, so
952
+ // their ⚡ must reflect the effective subagent tier, not the main session tier.
953
+ const roleFast =
954
+ roleInfo.settingsPath === "task.agentModelOverrides"
955
+ ? this.#isFastForSubagentProvider(assigned.model.provider)
956
+ : this.#isFastForProvider(assigned.model.provider);
957
+ const fastSuffix = roleFast ? ` ${theme.icon.fast}` : "";
958
+ roleBadgeTokens.push(`${badge} ${theme.fg("dim", `(${thinkingLabel})`)}${fastSuffix}`);
938
959
  }
939
960
  }
961
+ // Active/current non-role row: show the fast glyph on the session's current
962
+ // model row even when it carries no role badge. Skip when a role token for
963
+ // this row already rendered the glyph (duplicate-glyph guard).
964
+ if (
965
+ !roleMatched &&
966
+ this.#currentModel !== undefined &&
967
+ modelsAreEqual(this.#currentModel, item.model) &&
968
+ this.#isFastForProvider(item.model.provider)
969
+ ) {
970
+ roleBadgeTokens.push(theme.icon.fast);
971
+ }
940
972
  const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : "";
941
973
 
942
974
  let line = "";
@@ -65,6 +65,11 @@ export class OAuthSelectorComponent extends Container {
65
65
  this.#validationGeneration += 1;
66
66
  this.#stopSpinner();
67
67
  }
68
+
69
+ dispose(): void {
70
+ this.stopValidation();
71
+ super.dispose();
72
+ }
68
73
  #loadProviders(): void {
69
74
  this.#allProviders = getOAuthProviders();
70
75
  }
@@ -131,6 +131,11 @@ export class MCPAddWizard extends Container {
131
131
  | null = null;
132
132
  #onTestConnectionCallback: ((config: MCPServerConfig) => Promise<void>) | null = null;
133
133
  #onRenderCallback: (() => void) | null = null;
134
+ #disposed = false;
135
+ #transitionTimers = new Set<NodeJS.Timeout>();
136
+ #healthCheckSpinner?: NodeJS.Timeout;
137
+ #healthCheckTimeout?: NodeJS.Timeout;
138
+ #asyncGeneration = 0;
134
139
 
135
140
  constructor(
136
141
  onComplete: (name: string, config: MCPServerConfig, scope: Scope) => void,
@@ -178,6 +183,39 @@ export class MCPAddWizard extends Container {
178
183
  this.#renderStep();
179
184
  }
180
185
 
186
+ dispose(): void {
187
+ if (this.#disposed) return;
188
+ this.#disposed = true;
189
+ this.#asyncGeneration += 1;
190
+ for (const timer of this.#transitionTimers) {
191
+ clearTimeout(timer);
192
+ }
193
+ this.#transitionTimers.clear();
194
+ this.#clearHealthCheckTimers();
195
+ super.dispose();
196
+ }
197
+
198
+ #scheduleTransition(callback: () => void, delay: number): void {
199
+ if (this.#disposed) return;
200
+ const timer = setTimeout(() => {
201
+ this.#transitionTimers.delete(timer);
202
+ if (this.#disposed) return;
203
+ callback();
204
+ }, delay);
205
+ this.#transitionTimers.add(timer);
206
+ }
207
+
208
+ #clearHealthCheckTimers(): void {
209
+ if (this.#healthCheckSpinner) {
210
+ clearInterval(this.#healthCheckSpinner);
211
+ this.#healthCheckSpinner = undefined;
212
+ }
213
+ if (this.#healthCheckTimeout) {
214
+ clearTimeout(this.#healthCheckTimeout);
215
+ this.#healthCheckTimeout = undefined;
216
+ }
217
+ }
218
+
181
219
  #requestRender(): void {
182
220
  this.#onRenderCallback?.();
183
221
  }
@@ -941,6 +979,8 @@ export class MCPAddWizard extends Container {
941
979
  * Test connection and automatically detect if auth is needed.
942
980
  */
943
981
  async #testConnectionAndDetectAuth(): Promise<void> {
982
+ if (this.#disposed) return;
983
+ const generation = ++this.#asyncGeneration;
944
984
  const testConfig = this.#buildServerConfig();
945
985
 
946
986
  if (!this.#onTestConnectionCallback) {
@@ -954,6 +994,7 @@ export class MCPAddWizard extends Container {
954
994
  try {
955
995
  // Try to connect - timeout is handled by the transport layer (5 seconds)
956
996
  await this.#onTestConnectionCallback(testConfig);
997
+ if (this.#disposed || generation !== this.#asyncGeneration) return;
957
998
 
958
999
  // Success! No auth required
959
1000
  this.#contentContainer.clear();
@@ -962,7 +1003,7 @@ export class MCPAddWizard extends Container {
962
1003
  this.#contentContainer.addChild(new Text("No authentication required", 0, 0));
963
1004
  this.#contentContainer.addChild(new Spacer(1));
964
1005
 
965
- setTimeout(() => {
1006
+ this.#scheduleTransition(() => {
966
1007
  this.#state.authMethod = "none";
967
1008
  this.#currentStep = "scope";
968
1009
  this.#selectedIndex = 0;
@@ -978,10 +1019,12 @@ export class MCPAddWizard extends Container {
978
1019
  if (!oauth && this.#state.transport !== "stdio" && this.#state.url) {
979
1020
  try {
980
1021
  oauth = await discoverOAuthEndpoints(this.#state.url, authResult.authServerUrl);
1022
+ if (this.#disposed || generation !== this.#asyncGeneration) return;
981
1023
  } catch {
982
1024
  // Ignore discovery failures and fallback to manual auth.
983
1025
  }
984
1026
  }
1027
+ if (this.#disposed || generation !== this.#asyncGeneration) return;
985
1028
 
986
1029
  if (oauth) {
987
1030
  this.#state.oauthAuthUrl = oauth.authorizationUrl;
@@ -1019,7 +1062,7 @@ export class MCPAddWizard extends Container {
1019
1062
  this.#contentContainer.addChild(new Spacer(1));
1020
1063
  this.#contentContainer.addChild(new Text(theme.fg("muted", "Adding server anyway..."), 0, 0));
1021
1064
 
1022
- setTimeout(() => {
1065
+ this.#scheduleTransition(() => {
1023
1066
  this.#state.authMethod = "none";
1024
1067
  this.#currentStep = "scope";
1025
1068
  this.#selectedIndex = 0;
@@ -1107,6 +1150,8 @@ export class MCPAddWizard extends Container {
1107
1150
  }
1108
1151
 
1109
1152
  async #launchOAuthFlow(): Promise<void> {
1153
+ if (this.#disposed) return;
1154
+ const generation = ++this.#asyncGeneration;
1110
1155
  if (!this.#onOAuthCallback) {
1111
1156
  this.#contentContainer.clear();
1112
1157
  this.#contentContainer.addChild(new Text(theme.fg("error", "OAuth flow not available"), 0, 0));
@@ -1150,6 +1195,7 @@ export class MCPAddWizard extends Container {
1150
1195
  this.#state.oauthClientSecret,
1151
1196
  this.#state.oauthScopes,
1152
1197
  );
1198
+ if (this.#disposed || generation !== this.#asyncGeneration) return;
1153
1199
 
1154
1200
  // Store credential ID + any dynamically-registered client credentials,
1155
1201
  // so the final mcp.json entry persists everything needed for refresh.
@@ -1168,7 +1214,8 @@ export class MCPAddWizard extends Container {
1168
1214
  this.#contentContainer.addChild(healthText);
1169
1215
 
1170
1216
  let spinnerIndex = 0;
1171
- const spinner = setInterval(() => {
1217
+ this.#healthCheckSpinner = setInterval(() => {
1218
+ if (this.#disposed || generation !== this.#asyncGeneration) return;
1172
1219
  healthText.setText(
1173
1220
  theme.fg("muted", `${spinnerFrames[spinnerIndex % spinnerFrames.length]} Checking server connection...`),
1174
1221
  );
@@ -1181,7 +1228,7 @@ export class MCPAddWizard extends Container {
1181
1228
  if (this.#onTestConnectionCallback) {
1182
1229
  try {
1183
1230
  const { promise: timeoutPromise, reject: timeoutReject } = Promise.withResolvers<never>();
1184
- const timer = setTimeout(
1231
+ this.#healthCheckTimeout = setTimeout(
1185
1232
  () => timeoutReject(new Error("Health check timed out after 10 seconds")),
1186
1233
  10_000,
1187
1234
  );
@@ -1191,15 +1238,19 @@ export class MCPAddWizard extends Container {
1191
1238
  timeoutPromise,
1192
1239
  ]);
1193
1240
  } finally {
1194
- clearTimeout(timer);
1241
+ if (this.#healthCheckTimeout) {
1242
+ clearTimeout(this.#healthCheckTimeout);
1243
+ this.#healthCheckTimeout = undefined;
1244
+ }
1195
1245
  }
1196
1246
  } catch (error) {
1197
1247
  healthPassed = false;
1198
1248
  healthError = sanitize(error instanceof Error ? error.message : String(error));
1199
1249
  }
1200
1250
  }
1251
+ if (this.#disposed || generation !== this.#asyncGeneration) return;
1201
1252
 
1202
- clearInterval(spinner);
1253
+ this.#clearHealthCheckTimers();
1203
1254
  if (healthPassed) {
1204
1255
  healthText.setText(theme.fg("success", "✓ Health check passed"));
1205
1256
  } else {
@@ -1210,7 +1261,7 @@ export class MCPAddWizard extends Container {
1210
1261
  this.#requestRender();
1211
1262
 
1212
1263
  // Move to scope selection after short delay
1213
- setTimeout(
1264
+ this.#scheduleTransition(
1214
1265
  () => {
1215
1266
  this.#currentStep = "scope";
1216
1267
  this.#selectedIndex = 0;
@@ -1,6 +1,6 @@
1
1
  import type { TextContent } from "@gajae-code/ai";
2
2
  import type { Component } from "@gajae-code/tui";
3
- import { Box, Container, Markdown, Spacer, Text } from "@gajae-code/tui";
3
+ import { Box, Container, Markdown, Spacer, Text, truncateToWidth } from "@gajae-code/tui";
4
4
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
5
5
  import type { CustomMessage, SkillPromptDetails } from "../../session/messages";
6
6
 
@@ -39,29 +39,37 @@ export class SkillMessageComponent extends Container {
39
39
  this.addChild(this.#box);
40
40
  this.#box.clear();
41
41
 
42
- const label = theme.fg("customMessageLabel", theme.bold("[skill]"));
43
- this.#box.addChild(new Text(label, 0, 0));
44
- this.#box.addChild(new Spacer(1));
45
-
46
42
  const details = this.message.details;
43
+ const name = details?.name ?? "unknown";
47
44
  const args = details?.args?.trim();
48
- const infoLines = [
49
- `Skill: ${details?.name ?? "unknown"}`,
50
- args ? `Args: ${args}` : undefined,
51
- details?.path ? `Path: ${details.path}` : undefined,
52
- typeof details?.lineCount === "number" ? `Prompt: ${details.lineCount} lines` : undefined,
53
- ].filter((line): line is string => Boolean(line));
54
45
 
55
- this.#box.addChild(
56
- new Markdown(infoLines.join("\n"), 0, 0, getMarkdownTheme(), {
57
- color: (value: string) => theme.fg("customMessageText", value),
58
- }),
59
- );
46
+ // Single compact line: `[skill] <name>: <args>`. The summary is the
47
+ // args the user typed; with none, just `[skill] <name>`. Collapsed to
48
+ // one line path / line-count / full prompt body are debugging detail
49
+ // and only render once expanded.
50
+ const summary = args ? truncateToWidth(args.replace(/\s+/g, " "), 72) : undefined;
51
+ const header = `${theme.fg("customMessageLabel", theme.bold("[skill]"))} ${theme.fg("customMessageText", name)}`;
52
+ const headerText = summary ? `${header}${theme.fg("customMessageText", `: ${summary}`)}` : header;
53
+ this.#box.addChild(new Text(headerText, 0, 0));
60
54
 
61
55
  if (!this.#expanded) {
62
56
  return;
63
57
  }
64
58
 
59
+ const detailLines = [
60
+ details?.path ? `Path: ${details.path}` : undefined,
61
+ typeof details?.lineCount === "number" ? `Prompt: ${details.lineCount} lines` : undefined,
62
+ ].filter((line): line is string => Boolean(line));
63
+
64
+ if (detailLines.length > 0) {
65
+ this.#box.addChild(new Spacer(1));
66
+ this.#box.addChild(
67
+ new Markdown(detailLines.join("\n"), 0, 0, getMarkdownTheme(), {
68
+ color: (value: string) => theme.fg("customMessageText", value),
69
+ }),
70
+ );
71
+ }
72
+
65
73
  const text = this.#extractText();
66
74
  if (!text) {
67
75
  return;