@gajae-code/coding-agent 0.16.0 → 0.16.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 (164) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +15 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +2 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  39. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  40. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  41. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  42. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  43. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  44. package/dist/types/sdk/receipt-state.d.ts +2 -0
  45. package/dist/types/sdk/router/session-router.d.ts +4 -0
  46. package/dist/types/sdk/turn-result.d.ts +6 -0
  47. package/dist/types/session/agent-session.d.ts +47 -7
  48. package/dist/types/session/fold-coordinator.d.ts +29 -2
  49. package/dist/types/session/session-manager.d.ts +14 -2
  50. package/dist/types/tools/ask.d.ts +1 -0
  51. package/dist/types/tools/bash.d.ts +12 -0
  52. package/dist/types/tools/browser/launch.d.ts +10 -0
  53. package/dist/types/tools/browser/registry.d.ts +8 -0
  54. package/dist/types/tools/index.d.ts +4 -10
  55. package/dist/types/tools/job.d.ts +3 -1
  56. package/dist/types/utils/external-editor.d.ts +12 -0
  57. package/examples/extensions/README.md +1 -1
  58. package/examples/extensions/pirate.ts +7 -4
  59. package/package.json +7 -7
  60. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  61. package/src/async/job-manager.ts +107 -9
  62. package/src/browser-backend.ts +29 -0
  63. package/src/cli/plugin-cli.ts +29 -0
  64. package/src/cli/update-cli.ts +63 -12
  65. package/src/commands/sdk.ts +22 -7
  66. package/src/config/billing-path.ts +42 -0
  67. package/src/config/config-schema-version.ts +18 -1
  68. package/src/config/model-preset-registry.ts +177 -18
  69. package/src/config/model-registry.ts +268 -42
  70. package/src/config/settings-schema.ts +19 -7
  71. package/src/config/settings.ts +46 -15
  72. package/src/coordinator-mcp/projection-scan.ts +6 -6
  73. package/src/coordinator-mcp/server.ts +21 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/extensibility/extensions/runner.ts +27 -0
  76. package/src/extensibility/extensions/types.ts +8 -3
  77. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  78. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  79. package/src/extensibility/skills.ts +22 -12
  80. package/src/gjc-runtime/launch-worktree.ts +285 -4
  81. package/src/gjc-runtime/session-state-lock.ts +156 -32
  82. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  83. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  84. package/src/internal-urls/docs-index.generated.ts +11 -11
  85. package/src/main.ts +4 -1
  86. package/src/modes/acp/acp-agent.ts +34 -19
  87. package/src/modes/components/model-selector.ts +76 -8
  88. package/src/modes/components/session-selector.ts +23 -15
  89. package/src/modes/components/sessions-dashboard.ts +8 -5
  90. package/src/modes/controllers/command-controller.ts +1 -1
  91. package/src/modes/controllers/event-controller.ts +30 -4
  92. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  93. package/src/modes/controllers/input-controller.ts +2 -2
  94. package/src/modes/controllers/selector-controller.ts +10 -3
  95. package/src/modes/interactive-mode.ts +36 -12
  96. package/src/modes/prompt-action-autocomplete.ts +30 -4
  97. package/src/modes/runtime-init.ts +12 -9
  98. package/src/modes/theme/theme.ts +5 -0
  99. package/src/modes/types.ts +2 -0
  100. package/src/modes/utils/ui-helpers.ts +22 -3
  101. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  102. package/src/prompts/tools/ask.md +1 -1
  103. package/src/prompts/tools/bash.md +1 -0
  104. package/src/prompts/tools/job.md +1 -1
  105. package/src/sdk/acp/adapter.ts +6 -1
  106. package/src/sdk/broker/broker.ts +10 -1
  107. package/src/sdk/broker/ensure.ts +76 -3
  108. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  109. package/src/sdk/broker/lifecycle.ts +254 -34
  110. package/src/sdk/broker/spawn-substrate.ts +45 -2
  111. package/src/sdk/broker/startup-budget.ts +144 -2
  112. package/src/sdk/broker/state-version.ts +17 -0
  113. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  114. package/src/sdk/bus/chat-daemon-runtime.ts +118 -24
  115. package/src/sdk/bus/discord-daemon.ts +50 -10
  116. package/src/sdk/bus/index.ts +33 -3
  117. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  118. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  119. package/src/sdk/bus/notification-service.ts +22 -5
  120. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  121. package/src/sdk/bus/reconciliation-store.ts +55 -31
  122. package/src/sdk/bus/slack-conversation.ts +1 -1
  123. package/src/sdk/bus/slack-daemon.ts +72 -38
  124. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  125. package/src/sdk/cli/rows.ts +75 -7
  126. package/src/sdk/cli/session-cli.ts +68 -35
  127. package/src/sdk/host/control/dispatch.ts +2 -2
  128. package/src/sdk/host/events.ts +26 -2
  129. package/src/sdk/host/host.ts +6 -1
  130. package/src/sdk/host/query/cursor.ts +52 -7
  131. package/src/sdk/host/query/handlers.ts +33 -12
  132. package/src/sdk/host/query/revision-store.ts +12 -0
  133. package/src/sdk/host/session-runtime.test.ts +497 -31
  134. package/src/sdk/host/session-runtime.ts +387 -54
  135. package/src/sdk/host/surface-policy.ts +3 -3
  136. package/src/sdk/lifecycle-session.ts +7 -2
  137. package/src/sdk/prompt-deadline-manager.ts +2 -1
  138. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  139. package/src/sdk/protocol/operation-registry.ts +1 -1
  140. package/src/sdk/receipt-state.ts +10 -0
  141. package/src/sdk/router/session-router.ts +210 -94
  142. package/src/sdk/session.ts +59 -41
  143. package/src/sdk/turn-result.ts +22 -0
  144. package/src/session/agent-session.ts +1033 -260
  145. package/src/session/fold-coordinator.ts +40 -3
  146. package/src/session/session-manager.ts +517 -77
  147. package/src/slash-commands/builtin-registry.ts +41 -0
  148. package/src/task/executor.ts +9 -4
  149. package/src/tools/ask-contract.test.ts +64 -0
  150. package/src/tools/ask-contract.ts +36 -1
  151. package/src/tools/ask.ts +10 -8
  152. package/src/tools/bash-interactive.ts +11 -2
  153. package/src/tools/bash.ts +158 -33
  154. package/src/tools/browser/launch.ts +37 -0
  155. package/src/tools/browser/registry.ts +99 -9
  156. package/src/tools/cron.ts +6 -4
  157. package/src/tools/descriptors.test.ts +15 -0
  158. package/src/tools/descriptors.ts +5 -1
  159. package/src/tools/index.ts +4 -6
  160. package/src/tools/job.ts +4 -1
  161. package/src/tools/monitor.ts +12 -9
  162. package/src/tools/resolve.ts +30 -10
  163. package/src/tools/tool-catalog.generated.ts +2 -2
  164. package/src/utils/external-editor.ts +86 -1
package/src/main.ts CHANGED
@@ -453,6 +453,7 @@ type StartupModelProfileArgs = {
453
453
  startupModel?: CreateAgentSessionOptions["model"];
454
454
  startupThinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
455
455
  preferCachedModels?: boolean;
456
+ preferCachedDefaultProfile?: boolean;
456
457
  };
457
458
 
458
459
  async function applyStartupModelProfilesWithPolicy(
@@ -484,7 +485,9 @@ async function applyStartupModelProfilesWithPolicy(
484
485
  // deferred `--model <pattern>` path resolved inside createAgentSession.
485
486
  const explicitModel = args.parsedArgs.model ? (args.startupModel ?? args.session.model) : undefined;
486
487
  const defaultProfile = args.settings.get("modelProfile.default");
487
- const preferCachedProfiles = args.preferCachedModels === true && args.parsedArgs.mpreset !== undefined;
488
+ const preferCachedProfiles =
489
+ (args.preferCachedModels === true && args.parsedArgs.mpreset !== undefined) ||
490
+ (args.preferCachedDefaultProfile === true && defaultProfile !== undefined);
488
491
  const applyConfiguredProfiles = async (): Promise<boolean> => {
489
492
  let applied = true;
490
493
  if (defaultProfile) {
@@ -715,11 +715,11 @@ const ACP_CONFIG_OPTIONS = [
715
715
  ],
716
716
  },
717
717
  {
718
- id: "interruptMode",
719
- name: "Interrupt mode",
718
+ id: "toolInterruptPolicy",
719
+ name: "Tool interrupt policy",
720
720
  options: [
721
- { value: "immediate", name: "Immediate" },
722
- { value: "wait", name: "Wait" },
721
+ { value: "abort_tools", name: "Abort running tools" },
722
+ { value: "finish_tools", name: "Finish running tools" },
723
723
  ],
724
724
  },
725
725
  ] as const;
@@ -727,7 +727,7 @@ const ACP_CONFIG_OPTIONS = [
727
727
  const ACP_CONFIG_CONTROL_OPERATIONS: Record<string, string> = {
728
728
  steeringMode: "queue.steering_mode.set",
729
729
  followUpMode: "queue.follow_up_mode.set",
730
- interruptMode: "queue.interrupt_mode.set",
730
+ toolInterruptPolicy: "queue.tool_interrupt_policy.set",
731
731
  };
732
732
 
733
733
  function configValues(query: unknown): Map<string, string> {
@@ -1699,9 +1699,13 @@ export class AcpAgent implements Agent {
1699
1699
  try {
1700
1700
  await record.adapter.ensureProviders();
1701
1701
  } catch (error) {
1702
- if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1702
+ if (waiter.settled || record.activePrompt !== waiter) {
1703
1703
  this.#retiredPromptAcknowledgements.delete(params.sessionId);
1704
- if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1704
+ return await response;
1705
+ }
1706
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
1707
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1708
+ await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1705
1709
  return await response;
1706
1710
  }
1707
1711
  if (record.activePrompt === waiter) {
@@ -1712,9 +1716,13 @@ export class AcpAgent implements Agent {
1712
1716
  }
1713
1717
  throw error;
1714
1718
  }
1715
- if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1719
+ if (waiter.settled || record.activePrompt !== waiter) {
1716
1720
  this.#retiredPromptAcknowledgements.delete(params.sessionId);
1717
- if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1721
+ return await response;
1722
+ }
1723
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
1724
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1725
+ await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1718
1726
  return await response;
1719
1727
  }
1720
1728
 
@@ -1739,8 +1747,11 @@ export class AcpAgent implements Agent {
1739
1747
  );
1740
1748
  }
1741
1749
  waiter.dispatched = true;
1742
- if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1743
- if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1750
+ if (waiter.settled || record.activePrompt !== waiter) {
1751
+ return await response;
1752
+ }
1753
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
1754
+ await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1744
1755
  return await response;
1745
1756
  }
1746
1757
 
@@ -1922,7 +1933,7 @@ export class AcpAgent implements Agent {
1922
1933
  // return the semantically meaningful `cancelled` stop reason, so that Clients
1923
1934
  // can reliably confirm the cancellation." Surfacing the transport's `busy`
1924
1935
  // rejection instead would show the user a spurious error for their own cancel.
1925
- if (record.cancelRequested) {
1936
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
1926
1937
  record.cancelRequested = false;
1927
1938
  // The client's turn is settled by the return; the advisory idle publication
1928
1939
  // must not gate it and must still be attempted so the running phase is
@@ -2006,11 +2017,6 @@ export class AcpAgent implements Agent {
2006
2017
  }
2007
2018
  waiter?.cancelAttemptResolve?.(true);
2008
2019
  } catch (error) {
2009
- if (waiter && !waiter.dispatched) {
2010
- await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2011
- waiter.cancelAttemptResolve?.(true);
2012
- return;
2013
- }
2014
2020
  if (!waiter) record.cancelRequested = false;
2015
2021
  // Only the LAST in-flight attempt resolves the shared promise false;
2016
2022
  // an earlier attempt may still acknowledge (review thread P2). After
@@ -2058,7 +2064,8 @@ export class AcpAgent implements Agent {
2058
2064
 
2059
2065
  async #settleCancelledPrompt(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
2060
2066
  // The authoritative terminal wins whenever it arrives in time; this only runs
2061
- // when nothing settled the prompt the client already asked to cancel.
2067
+ // when nothing settled the prompt after the SDK acknowledged the cancellation.
2068
+ if (!waiter.cancelAcknowledged) return;
2062
2069
  if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
2063
2070
  if (waiter.terminalReserved) return;
2064
2071
  record.activePrompt = undefined;
@@ -2896,6 +2903,14 @@ export class AcpAgent implements Agent {
2896
2903
  if (reconnected) {
2897
2904
  const waiter = record.activePrompt;
2898
2905
  if (waiter && !waiter.settled && !waiter.terminal) {
2906
+ // A successful abort acknowledgement is authoritative for this prompt even
2907
+ // when the SDK transport changes identity during the cancellation grace.
2908
+ // Treating the reconnect as owner loss would replace the acknowledged
2909
+ // cancellation with connection_closed and leave cancellation nondeterministic.
2910
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
2911
+ await this.#settleCancelledPrompt(id, record, waiter);
2912
+ return;
2913
+ }
2899
2914
  record.activePrompt = undefined;
2900
2915
  record.busy = record.backgroundBusy;
2901
2916
  clearPromptWatchdog(waiter);
@@ -3113,7 +3128,7 @@ export class AcpAgent implements Agent {
3113
3128
  id: string,
3114
3129
  notification: SessionNotification,
3115
3130
  adapter: AcpSdkAdapter,
3116
- publicationGeneration: number,
3131
+ _publicationGeneration: number,
3117
3132
  ): void {
3118
3133
  const prior = this.#failureDiagnosticTails.get(id) ?? Promise.resolve();
3119
3134
  const task = prior.then(async () => {
@@ -22,6 +22,7 @@ import {
22
22
  normalizeTierMap,
23
23
  validateAutoroutingSetup,
24
24
  } from "../../config/autorouting-contract";
25
+ import type { BillingPath } from "../../config/billing-path";
25
26
  import {
26
27
  getProxyRoutableProviders,
27
28
  inspectProxyProviderId,
@@ -30,7 +31,6 @@ import {
30
31
  rewriteSelectorForProxy,
31
32
  tryResolveProxyProviderId,
32
33
  } from "../../config/model-profile-activation";
33
-
34
34
  import { isModelProfileProviderAvailable } from "../../config/model-profile-contract";
35
35
  import {
36
36
  deriveModelProfileMappedProviders,
@@ -429,6 +429,9 @@ export class ModelSelectorComponent extends Container {
429
429
  #imageRoleFilter: boolean = false;
430
430
  #smartRoutingPanel?: SmartRoutingPanelComponent;
431
431
  #smartRoutingPreviewBuilder?: (draft: AutoroutingSetup) => SmartRoutingPreview;
432
+ #isBillingDismissed?: (key: string) => boolean;
433
+ #markBillingDismissed?: (key: string) => void;
434
+ #pendingBillingNoticeKey?: string;
432
435
 
433
436
  // Tab state
434
437
  #providers: ProviderTabState[] = STATIC_PROVIDER_TABS;
@@ -455,6 +458,10 @@ export class ModelSelectorComponent extends Container {
455
458
  smartRoutingPreview?: (draft: AutoroutingSetup) => SmartRoutingPreview;
456
459
  /** Open the smart-routing panel directly instead of the preset landing. */
457
460
  smartRoutingOnly?: boolean;
461
+ /** Test seam: synchronous persisted-dismissal check to avoid async render churn. */
462
+ isBillingDismissed?: (key: string) => boolean;
463
+ /** Test seam: record that a billing notice was shown/dismissed. */
464
+ markBillingDismissed?: (key: string) => void;
458
465
  },
459
466
  ) {
460
467
  super();
@@ -468,6 +475,8 @@ export class ModelSelectorComponent extends Container {
468
475
  this.#temporaryOnly = options?.temporaryOnly ?? false;
469
476
  this.#authSessionId = options?.sessionId;
470
477
  this.#smartRoutingPreviewBuilder = options?.smartRoutingPreview;
478
+ this.#isBillingDismissed = (options as any)?.isBillingDismissed;
479
+ this.#markBillingDismissed = (options as any)?.markBillingDismissed;
471
480
  this.#currentModel = _currentModel;
472
481
  this.#currentThinkingLevel = options?.currentThinkingLevel;
473
482
  this.#activeModelProfile = options?.activeModelProfile;
@@ -1497,6 +1506,25 @@ export class ModelSelectorComponent extends Container {
1497
1506
  const availableModels = this.#getProfileAvailableModels();
1498
1507
  const resolutionRegistry = this.#createProfileResolutionRegistry(availableModels);
1499
1508
  const providers = new Set<string>();
1509
+ // Preset groups share selectors heavily (role assignments repeat across
1510
+ // profiles), and each `resolveModelRoleValue` call scans the candidate set
1511
+ // while validating lookup contexts and resolving aliases. Resolve identical
1512
+ // selectors once per refresh; the inputs are shared and only the provider is
1513
+ // needed here. This map is deliberately local to the synchronous, pre-await
1514
+ // snapshot below: catalog/config changes and credential-generation changes
1515
+ // start a new refresh with a new map.
1516
+ const resolvedProviderBySelector = new Map<string, string | undefined>();
1517
+ const resolveSelectorProvider = (selector: string): string | undefined => {
1518
+ if (resolvedProviderBySelector.has(selector)) return resolvedProviderBySelector.get(selector);
1519
+ const provider = resolveModelRoleValue(selector, availableModels, {
1520
+ settings: this.#settings,
1521
+ modelRegistry: this.#modelRegistry,
1522
+ aliasIntent: "preset-equivalent",
1523
+ credentialSessionId: this.#authSessionId,
1524
+ }).model?.provider;
1525
+ resolvedProviderBySelector.set(selector, provider);
1526
+ return provider;
1527
+ };
1500
1528
  for (const profiles of this.#getPresetGroups().values()) {
1501
1529
  for (const profile of profiles) {
1502
1530
  for (const provider of profileRequiredProviders(profile)) providers.add(provider);
@@ -1516,13 +1544,8 @@ export class ModelSelectorComponent extends Container {
1516
1544
  ];
1517
1545
  for (const value of values) {
1518
1546
  for (const selector of normalizeModelSelectorValue(value)) {
1519
- const resolved = resolveModelRoleValue(selector, availableModels, {
1520
- settings: this.#settings,
1521
- modelRegistry: this.#modelRegistry,
1522
- aliasIntent: "preset-equivalent",
1523
- credentialSessionId: this.#authSessionId,
1524
- }).model;
1525
- if (resolved) providers.add(resolved.provider);
1547
+ const resolvedProvider = resolveSelectorProvider(selector);
1548
+ if (resolvedProvider !== undefined) providers.add(resolvedProvider);
1526
1549
  }
1527
1550
  }
1528
1551
  }
@@ -1705,12 +1728,24 @@ export class ModelSelectorComponent extends Container {
1705
1728
  return lines;
1706
1729
  }
1707
1730
 
1731
+ #billingPathForModel(model: Model): BillingPath | undefined {
1732
+ try {
1733
+ const fn = (
1734
+ this.#modelRegistry as unknown as { getBillingPath?: (m: Model, s?: string) => BillingPath | undefined }
1735
+ ).getBillingPath;
1736
+ if (typeof fn === "function") return fn.call(this.#modelRegistry, model, this.#authSessionId);
1737
+ } catch {}
1738
+ return undefined;
1739
+ }
1740
+
1708
1741
  #formatAssignedModelLabel(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
1709
1742
  const modelLabel = sanitizeText(`${model.provider}/${model.id}`).replace(/\s+/g, " ").trim();
1710
1743
  let label = modelLabel;
1711
1744
  if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
1712
1745
  label += ` (${getThinkingLevelMetadata(thinkingLevel).label})`;
1713
1746
  }
1747
+ const billing = this.#billingPathForModel(model)?.label;
1748
+ if (billing) label += ` [${billing}]`;
1714
1749
  return truncateToWidth(label, ROLE_BINDING_MAX_WIDTH);
1715
1750
  }
1716
1751
 
@@ -1914,6 +1949,27 @@ export class ModelSelectorComponent extends Container {
1914
1949
  this.#listContainer.addChild(new Spacer(1));
1915
1950
  this.#listContainer.addChild(new Text(theme.fg("warning", ` ${this.#presetLoginHint}`), 0, 0));
1916
1951
  }
1952
+ // One-time metered billing notice for the currently selected model (first bind per credential source)
1953
+ try {
1954
+ const sel = this.#getSelectedItem?.() as any;
1955
+ const m = sel?.model as any;
1956
+ if (m && this.#viewMode === "models") {
1957
+ const bp = this.#billingPathForModel(m);
1958
+ if (bp && !(this.#isBillingDismissed?.(bp.dismissKey) ?? false)) {
1959
+ this.#listContainer.addChild(new Spacer(1));
1960
+ this.#listContainer.addChild(
1961
+ new Text(
1962
+ theme.fg("warning", ` Billing: ${bp.label} — charges may apply. Press d to dismiss.`),
1963
+ 0,
1964
+ 0,
1965
+ ),
1966
+ );
1967
+ this.#pendingBillingNoticeKey = bp.dismissKey;
1968
+ } else {
1969
+ this.#pendingBillingNoticeKey = undefined;
1970
+ }
1971
+ }
1972
+ } catch {}
1917
1973
  const previewProfile = this.#getProfileByName(this.#previewProfileName);
1918
1974
  if (previewProfile) this.#renderPresetPreview(previewProfile);
1919
1975
  }
@@ -2075,6 +2131,8 @@ export class ModelSelectorComponent extends Container {
2075
2131
  ) {
2076
2132
  roleBadgeTokens.push(theme.icon.fast);
2077
2133
  }
2134
+ const billingLabel = this.#billingPathForModel(item.model)?.label;
2135
+ if (billingLabel) roleBadgeTokens.push(theme.fg("warning", `[${billingLabel}]`));
2078
2136
  const badgeText = roleBadgeTokens.length > 0 ? ` ${roleBadgeTokens.join(" ")}` : "";
2079
2137
 
2080
2138
  let line = "";
@@ -2307,6 +2365,16 @@ export class ModelSelectorComponent extends Container {
2307
2365
  return;
2308
2366
  }
2309
2367
 
2368
+ if (keyData === "d" || keyData === "D") {
2369
+ if (this.#pendingBillingNoticeKey) {
2370
+ try {
2371
+ this.#markBillingDismissed?.(this.#pendingBillingNoticeKey);
2372
+ } catch {}
2373
+ this.#pendingBillingNoticeKey = undefined;
2374
+ this.#updateList();
2375
+ return;
2376
+ }
2377
+ }
2310
2378
  // Pass everything else to search input
2311
2379
  this.#searchInput.handleInput(keyData);
2312
2380
  this.#filterModels(this.#searchInput.getValue());
@@ -14,7 +14,12 @@ import {
14
14
  import { formatBytes } from "@gajae-code/utils";
15
15
  import { theme } from "../../modes/theme/theme";
16
16
  import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
17
- import type { ResumeSessionIdentity, ResumeTailInspection, SessionInfo } from "../../session/session-manager";
17
+ import {
18
+ prioritizeStarredSessions,
19
+ type ResumeSessionIdentity,
20
+ type ResumeTailInspection,
21
+ type SessionInfo,
22
+ } from "../../session/session-manager";
18
23
  import { DynamicBorder } from "./dynamic-border";
19
24
  import { HookSelectorComponent } from "./hook-selector";
20
25
 
@@ -71,17 +76,19 @@ class SessionList implements Component {
71
76
  }
72
77
 
73
78
  #filterSessions(query: string): void {
74
- this.#filteredSessions = fuzzyFilter(this.allSessions, query, session =>
75
- [
76
- session.id,
77
- session.title ?? "",
78
- session.cwd ?? "",
79
- session.firstMessage ?? "",
80
- session.allMessagesText,
81
- session.path,
82
- ]
83
- .filter(Boolean)
84
- .join(" "),
79
+ this.#filteredSessions = prioritizeStarredSessions(
80
+ fuzzyFilter(this.allSessions, query, session =>
81
+ [
82
+ session.id,
83
+ session.title ?? "",
84
+ session.cwd ?? "",
85
+ session.firstMessage ?? "",
86
+ session.allMessagesText,
87
+ session.path,
88
+ ]
89
+ .filter(Boolean)
90
+ .join(" "),
91
+ ),
85
92
  );
86
93
  this.#clampSelectedIndex();
87
94
  }
@@ -131,8 +138,9 @@ class SessionList implements Component {
131
138
  const cursorWidth = visibleWidth(cursor);
132
139
  const prefix = selected ? theme.fg("accent", cursor) : padding(cursorWidth);
133
140
  const message = session.firstMessage.replace(/\n/g, " ").trim();
134
- const title = truncateToWidth(session.title ?? message, width - cursorWidth);
135
- lines.push(prefix + (selected ? theme.bold(title) : title));
141
+ const star = session.starred ? theme.fg("warning", `${theme.icon.star} `) : "";
142
+ const title = truncateToWidth(session.title ?? message, width - cursorWidth - visibleWidth(star));
143
+ lines.push(prefix + star + (selected ? theme.bold(title) : title));
136
144
  if (session.title) lines.push(` ${theme.fg("dim", truncateToWidth(message, width - cursorWidth))}`);
137
145
  lines.push(
138
146
  theme.fg(
@@ -211,7 +219,7 @@ export class SessionSelectorComponent extends Container {
211
219
  this.addChild(new DynamicBorder());
212
220
  this.addChild(new Spacer(1));
213
221
  this.addChild(this.#messageContainer);
214
- this.#sessionList = new SessionList(sessions);
222
+ this.#sessionList = new SessionList(prioritizeStarredSessions(sessions));
215
223
  this.#sessionList.onSelect = session => {
216
224
  if (this.inspector) void this.#inspect(session);
217
225
  else this.#dispatchSelect(session.path);
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
- import { type Component, Container, matchesKey, truncateToWidth } from "@gajae-code/tui";
3
- import type { SessionInfo } from "../../session/session-manager";
2
+ import { type Component, Container, matchesKey, truncateToWidth, visibleWidth } from "@gajae-code/tui";
3
+ import { prioritizeStarredSessions, type SessionInfo } from "../../session/session-manager";
4
4
  import { theme } from "../theme/theme";
5
5
 
6
6
  const PRESENCE_MAX_BYTES = 4096;
@@ -41,6 +41,7 @@ export interface DashboardSession {
41
41
  id: string;
42
42
  cwd: string;
43
43
  title: string;
44
+ starred: boolean;
44
45
  modified: Date;
45
46
  messageCount: number;
46
47
  messageCountIsEstimate?: boolean;
@@ -78,10 +79,11 @@ export function dashboardSessions(
78
79
  sessions: readonly SessionInfo[],
79
80
  options: { readFile?: (filePath: string) => string; now?: number } = {},
80
81
  ): DashboardSession[] {
81
- return sessions.map(session => ({
82
+ return prioritizeStarredSessions(sessions).map(session => ({
82
83
  id: session.id,
83
84
  cwd: session.cwd,
84
85
  title: session.title ?? session.firstMessage,
86
+ starred: session.starred,
85
87
  modified: session.modified,
86
88
  messageCountIsEstimate: session.messageCountIsEstimate,
87
89
  messageCount: session.messageCount,
@@ -169,10 +171,11 @@ class SessionDashboardRow implements Component {
169
171
  const fullMetadata = `· ${count} · ${modified}`;
170
172
  const compactMetadata = `· ${this.session.messageCountIsEstimate ? `~${this.session.messageCount}` : this.session.messageCount} msgs`;
171
173
  const metadata = width >= 48 ? fullMetadata : width >= 24 ? compactMetadata : "";
172
- const titleWidth = Math.max(0, width - 2 - metadata.length - (metadata ? 1 : 0));
174
+ const star = this.session.starred ? theme.fg("warning", `${theme.icon.star} `) : "";
175
+ const titleWidth = Math.max(0, width - 2 - visibleWidth(star) - metadata.length - (metadata ? 1 : 0));
173
176
  return [
174
177
  fit(
175
- ` ${status} ${truncateToWidth(title, titleWidth)}${metadata ? ` ${theme.fg("dim", metadata)}` : ""}`,
178
+ ` ${status} ${star}${truncateToWidth(title, titleWidth)}${metadata ? ` ${theme.fg("dim", metadata)}` : ""}`,
176
179
  width,
177
180
  ),
178
181
  fit(` ${theme.fg("dim", cwd)}`, width),
@@ -1161,7 +1161,7 @@ export class CommandController {
1161
1161
  }
1162
1162
 
1163
1163
  try {
1164
- await this.ctx.sessionManager.runExclusiveCwdTransition(async () => {
1164
+ await this.ctx.sessionManager.runExclusiveCwdMoveTransition(async () => {
1165
1165
  await this.ctx.sessionManager.flush();
1166
1166
  await this.ctx.sessionManager.moveTo(resolvedPath);
1167
1167
  setProjectDir(resolvedPath);
@@ -57,6 +57,17 @@ export const __eventControllerPerfCounters = {
57
57
  };
58
58
 
59
59
  const COMPLETION_NOTIFY_COMMAND_TIMEOUT_MS = 10_000;
60
+ const TEXTLESS_LENGTH_WARNING =
61
+ 'Response ended with stopReason "length" before producing visible text or a tool call. Check the model\'s output and context token limits (maxTokens in models.yml) or lower the reasoning level (/reasoning), then retry.';
62
+
63
+ function isTextlessLengthStop(message: AssistantMessage | undefined): boolean {
64
+ return (
65
+ message?.stopReason === "length" &&
66
+ !message.content.some(
67
+ block => (block.type === "text" && block.text.trim().length > 0) || block.type === "toolCall",
68
+ )
69
+ );
70
+ }
60
71
 
61
72
  interface CompletionNotifyPayload {
62
73
  type: "agent-turn-complete";
@@ -904,7 +915,7 @@ export class EventController {
904
915
  }
905
916
  }
906
917
 
907
- async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
918
+ async #handleAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
908
919
  this.ctx.setWorkingMessage(undefined);
909
920
  stopInteractiveActivityIndicator(this.ctx, { foregroundSettled: true });
910
921
  if (this.ctx.streamingComponent) {
@@ -929,6 +940,14 @@ export class EventController {
929
940
  this.ctx.ui.requestRender();
930
941
  this.#scheduleIdleCompaction();
931
942
  emitHostStatus("finished");
943
+ const lastAssistant = this.ctx.session.getLastAssistantMessage();
944
+ if (
945
+ event.stopReason !== "cancelled" &&
946
+ event.stopReason !== "maintenance" &&
947
+ isTextlessLengthStop(lastAssistant)
948
+ ) {
949
+ this.ctx.showWarning(TEXTLESS_LENGTH_WARNING);
950
+ }
932
951
  this.sendCompletionNotification();
933
952
  this.ctx.promptSuggestion?.onAgentEnd();
934
953
  }
@@ -1204,13 +1223,20 @@ export class EventController {
1204
1223
  // errored — those are not "Task complete" events. Mirrors the gate
1205
1224
  // already used by #currentContextTokens, #handleMessageEnd, and the
1206
1225
  // retry / TTSR / compaction skip paths across agent-session.ts.
1207
- const last = this.ctx.session.getLastAssistantMessage?.();
1226
+ const last = this.ctx.session.getLastAssistantMessage();
1208
1227
  if (last?.stopReason === "aborted" || last?.stopReason === "error") return;
1209
1228
 
1210
1229
  const sessionName = this.ctx.sessionManager.getSessionName();
1211
- const title = sessionName ? `${sessionName}: Complete` : "Complete";
1230
+ const textlessLengthStop = isTextlessLengthStop(last);
1231
+ const title = textlessLengthStop
1232
+ ? sessionName
1233
+ ? `${sessionName}: Output limit reached`
1234
+ : "Output limit reached"
1235
+ : sessionName
1236
+ ? `${sessionName}: Complete`
1237
+ : "Complete";
1212
1238
  const summary = summaryFromMessage(last, 1000);
1213
- const body = summary ?? "Complete";
1239
+ const body = textlessLengthStop ? TEXTLESS_LENGTH_WARNING : (summary ?? "Complete");
1214
1240
  const sessionManager = this.ctx.sessionManager as { getCwd?: () => string; getSessionId?: () => string };
1215
1241
  const payload: CompletionNotifyPayload = {
1216
1242
  type: "agent-turn-complete",
@@ -39,6 +39,7 @@ import {
39
39
  SYNTHETIC_PROVIDER_ID,
40
40
  syntheticNamespaceCollision,
41
41
  } from "../../sdk/model-profile-model";
42
+ import { bashBackgroundControlError } from "../../session/fold-coordinator";
42
43
  import { createReadonlySessionManager } from "../../session/session-manager";
43
44
  import { parseThinkingLevel } from "../../thinking";
44
45
  import type { TodoPhase } from "../../tools/todo-write";
@@ -311,12 +312,11 @@ export class ExtensionUiController {
311
312
  throw Object.assign(new Error("No retry backoff is pending."), { code: "retry_not_pending" });
312
313
  session.retryNow();
313
314
  return { retried: true, immediate: true };
314
- case "bash.background":
315
- if (!(await session.requestForegroundBashBackground()))
316
- throw Object.assign(new Error("The active bash command cannot be moved to a managed background job."), {
317
- code: "not_foldable",
318
- });
319
- return { backgrounded: true };
315
+ case "bash.background": {
316
+ const outcome = await session.requestForegroundBashBackgroundOutcome("sdk_control");
317
+ if (outcome.status !== "folded") throw bashBackgroundControlError(outcome);
318
+ return { backgrounded: true, jobId: outcome.jobId };
319
+ }
320
320
  case "compaction.auto.set":
321
321
  session.setAutoCompactionEnabled(input.on === true);
322
322
  return { changed: true };
@@ -591,8 +591,8 @@ export class ExtensionUiController {
591
591
  this.ctx.session.setFollowUpMode(mode);
592
592
  return true;
593
593
  }
594
- if (kind === "interrupt" && (mode === "immediate" || mode === "wait")) {
595
- this.ctx.session.setInterruptMode(mode);
594
+ if (kind === "tool_interrupt" && (mode === "abort_tools" || mode === "finish_tools")) {
595
+ this.ctx.session.setToolInterruptPolicy(mode);
596
596
  return true;
597
597
  }
598
598
  return false;
@@ -821,8 +821,8 @@ export class ExtensionUiController {
821
821
  this.ctx.session.setFollowUpMode(mode);
822
822
  return true;
823
823
  }
824
- if (kind === "interrupt" && (mode === "immediate" || mode === "wait")) {
825
- this.ctx.session.setInterruptMode(mode);
824
+ if (kind === "tool_interrupt" && (mode === "abort_tools" || mode === "finish_tools")) {
825
+ this.ctx.session.setToolInterruptPolicy(mode);
826
826
  return true;
827
827
  }
828
828
  return false;
@@ -962,8 +962,8 @@ export class ExtensionUiController {
962
962
  this.ctx.session.setFollowUpMode(mode);
963
963
  return true;
964
964
  }
965
- if (kind === "interrupt" && (mode === "immediate" || mode === "wait")) {
966
- this.ctx.session.setInterruptMode(mode);
965
+ if (kind === "tool_interrupt" && (mode === "abort_tools" || mode === "finish_tools")) {
966
+ this.ctx.session.setToolInterruptPolicy(mode);
967
967
  return true;
968
968
  }
969
969
  return false;
@@ -973,7 +973,7 @@ export class ExtensionUiController {
973
973
  getConfigItems: () => ({
974
974
  steeringMode: this.ctx.session.steeringMode,
975
975
  followUpMode: this.ctx.session.followUpMode,
976
- interruptMode: this.ctx.session.interruptMode,
976
+ toolInterruptPolicy: this.ctx.session.toolInterruptPolicy,
977
977
  }),
978
978
  getBranchCandidates: () => this.ctx.sessionManager.getTree(),
979
979
  getExtensions: () => this.ctx.session.extensionRunner?.getExtensionPaths() ?? [],
@@ -1170,7 +1170,7 @@ export class InputController {
1170
1170
  const promptOptions =
1171
1171
  streamingBehavior === "followUp"
1172
1172
  ? { streamingBehavior, images, followUpQueuePolicy: "sequential" as const }
1173
- : { streamingBehavior, images };
1173
+ : { streamingBehavior, images, steerQueuePolicy: "sequential" as const };
1174
1174
  await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, promptOptions), {
1175
1175
  imageCount: images?.length ?? 0,
1176
1176
  });
@@ -1683,7 +1683,7 @@ export class InputController {
1683
1683
  },
1684
1684
  streamingBehavior === "followUp"
1685
1685
  ? { streamingBehavior, followUpQueuePolicy: "sequential" }
1686
- : { streamingBehavior },
1686
+ : { streamingBehavior, steerQueuePolicy: "sequential" },
1687
1687
  );
1688
1688
  }
1689
1689
  if (this.ctx.session.isStreaming) {
@@ -7,6 +7,7 @@ import {
7
7
  isSqliteCorruptionError,
8
8
  isSqliteError,
9
9
  type Model,
10
+ OAuthCredentialSelectorError,
10
11
  resolveOAuthStorageProvider,
11
12
  } from "@gajae-code/ai/core";
12
13
  import { getOAuthProviders } from "@gajae-code/ai/utils/oauth";
@@ -2243,8 +2244,8 @@ export class SelectorController {
2243
2244
  case "followUpMode":
2244
2245
  this.ctx.session.setFollowUpMode(value as "all" | "one-at-a-time");
2245
2246
  break;
2246
- case "interruptMode":
2247
- this.ctx.session.setInterruptMode(value as "immediate" | "wait");
2247
+ case "toolInterruptPolicy":
2248
+ this.ctx.session.setToolInterruptPolicy(value as "abort_tools" | "finish_tools");
2248
2249
  break;
2249
2250
  case "thinkingLevel":
2250
2251
  case "defaultThinkingLevel":
@@ -3706,7 +3707,13 @@ export class SelectorController {
3706
3707
  {
3707
3708
  accountProviderId: selectedProviderId,
3708
3709
  onAccountSelect: async (selectorValue: AuthCredentialSelector) => {
3709
- await this.ctx.session.setCredentialPin(selectedProviderId, selectorValue);
3710
+ try {
3711
+ await this.ctx.session.setCredentialPin(selectedProviderId, selectorValue);
3712
+ } catch (error: unknown) {
3713
+ if (!(error instanceof OAuthCredentialSelectorError)) throw error;
3714
+ this.ctx.showError(error.message);
3715
+ return;
3716
+ }
3710
3717
  selector.stopValidation();
3711
3718
  done();
3712
3719
  this.ctx.showStatus(`Pinned OAuth account for ${selectedProviderId} to this session.`);