@gajae-code/coding-agent 0.11.5 → 0.11.7

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 (138) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/commands/daemon.d.ts +2 -2
  5. package/dist/types/commands/sdk.d.ts +20 -2
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +67 -7
  8. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
  10. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
  12. package/dist/types/main.d.ts +24 -1
  13. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  14. package/dist/types/modes/components/model-selector.d.ts +2 -0
  15. package/dist/types/modes/components/tool-status-header.d.ts +1 -0
  16. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -0
  21. package/dist/types/sdk/bus/index.d.ts +1 -1
  22. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  23. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  27. package/dist/types/sdk/client/discovery.d.ts +2 -0
  28. package/dist/types/sdk/client/liveness.d.ts +7 -0
  29. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  30. package/dist/types/sdk/transport/index.d.ts +12 -0
  31. package/dist/types/sdk/transport/relay.d.ts +43 -0
  32. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  33. package/dist/types/sdk/transport/socket.d.ts +7 -0
  34. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  35. package/dist/types/session/agent-session.d.ts +28 -5
  36. package/dist/types/session/btw-contract.d.ts +16 -0
  37. package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
  38. package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
  39. package/dist/types/session/session-manager.d.ts +35 -4
  40. package/dist/types/session/yield-queue.d.ts +8 -0
  41. package/dist/types/setup/credential-import.d.ts +5 -1
  42. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  43. package/dist/types/ssh/utils.d.ts +1 -0
  44. package/dist/types/task/discovery.d.ts +3 -1
  45. package/dist/types/tools/fetch.d.ts +5 -8
  46. package/dist/types/tools/image-gen.d.ts +16 -0
  47. package/dist/types/tools/skill-discovery.d.ts +7 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  49. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  50. package/dist/types/web/insane/bridge.d.ts +1 -0
  51. package/dist/types/web/insane/url-guard.d.ts +17 -0
  52. package/package.json +8 -8
  53. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  54. package/src/capability/index.ts +50 -42
  55. package/src/capability/types.ts +3 -0
  56. package/src/cli/setup-cli.ts +10 -4
  57. package/src/cli.ts +34 -9
  58. package/src/commands/daemon.ts +22 -5
  59. package/src/commands/sdk.ts +42 -9
  60. package/src/config/keybindings.ts +1 -1
  61. package/src/config/model-profiles.ts +18 -0
  62. package/src/config/settings-schema.ts +71 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  64. package/src/discovery/ssh.ts +12 -1
  65. package/src/eval/py/kernel.ts +1 -21
  66. package/src/eval/py/runner-artifact.ts +84 -0
  67. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  68. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  69. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  71. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  72. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/internal-urls/local-protocol.ts +32 -6
  75. package/src/main.ts +87 -28
  76. package/src/modes/action-registry.ts +1 -1
  77. package/src/modes/components/btw-panel.ts +82 -16
  78. package/src/modes/components/model-selector.ts +31 -3
  79. package/src/modes/components/tool-status-header.ts +6 -1
  80. package/src/modes/controllers/btw-controller.ts +119 -36
  81. package/src/modes/controllers/command-controller.ts +15 -0
  82. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  83. package/src/modes/controllers/input-controller.ts +31 -0
  84. package/src/modes/controllers/selector-controller.ts +106 -12
  85. package/src/modes/interactive-mode.ts +16 -7
  86. package/src/modes/runtime-init.ts +16 -2
  87. package/src/modes/types.ts +1 -0
  88. package/src/prompts/system/btw-user.md +3 -8
  89. package/src/prompts/tools/skill-discovery.md +1 -0
  90. package/src/runtime-mcp/client.ts +41 -55
  91. package/src/runtime-mcp/content-limits.ts +71 -0
  92. package/src/runtime-mcp/json-rpc.ts +23 -9
  93. package/src/runtime-mcp/transports/http.ts +50 -16
  94. package/src/sdk/bus/chat-daemon-control.ts +20 -7
  95. package/src/sdk/bus/index.ts +8 -7
  96. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  97. package/src/sdk/bus/notification-service.ts +41 -18
  98. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  99. package/src/sdk/bus/telegram-daemon-control.ts +26 -8
  100. package/src/sdk/bus/telegram-daemon.ts +1736 -338
  101. package/src/sdk/bus/topic-registry.ts +451 -12
  102. package/src/sdk/client/discovery.ts +19 -3
  103. package/src/sdk/client/liveness.ts +49 -0
  104. package/src/sdk/host/control/dispatch.ts +14 -5
  105. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  106. package/src/sdk/session.ts +29 -5
  107. package/src/sdk/transport/auth-preface.ts +82 -0
  108. package/src/sdk/transport/index.ts +18 -0
  109. package/src/sdk/transport/relay.ts +252 -0
  110. package/src/sdk/transport/serve-cli.ts +105 -0
  111. package/src/sdk/transport/socket.ts +197 -0
  112. package/src/sdk/transport/stdio.ts +17 -0
  113. package/src/session/agent-session.ts +1212 -768
  114. package/src/session/blob-store.ts +19 -6
  115. package/src/session/btw-contract.ts +59 -0
  116. package/src/session/internal/managed-session-scope.ts +313 -57
  117. package/src/session/internal/managed-session-storage.ts +146 -35
  118. package/src/session/session-manager.ts +385 -85
  119. package/src/session/yield-queue.ts +20 -0
  120. package/src/setup/credential-import.ts +28 -3
  121. package/src/setup/provider-onboarding.ts +30 -1
  122. package/src/setup/provider-presets.json +2 -1
  123. package/src/slash-commands/builtin-registry.ts +37 -1
  124. package/src/ssh/utils.ts +14 -0
  125. package/src/task/discovery.ts +8 -2
  126. package/src/tools/ask-answer-registry.ts +15 -1
  127. package/src/tools/ask.ts +1 -1
  128. package/src/tools/fetch.ts +13 -131
  129. package/src/tools/image-gen.ts +301 -20
  130. package/src/tools/read.ts +2 -0
  131. package/src/tools/skill-discovery.ts +39 -3
  132. package/src/tools/sqlite-reader.ts +63 -5
  133. package/src/utils/pasted-image-path.ts +15 -6
  134. package/src/utils/shell-snapshot.ts +173 -63
  135. package/src/web/insane/bridge.ts +5 -1
  136. package/src/web/insane/url-guard.ts +138 -27
  137. package/src/web/scrapers/types.ts +19 -49
  138. package/src/web/scrapers/utils.ts +12 -26
@@ -169,6 +169,26 @@ async function snapshotDirectory(directoryPath: string, relativePath: string): P
169
169
  return { relativePath, dev: stat.dev, ino: stat.ino, size: stat.size };
170
170
  }
171
171
 
172
+ interface CanonicalLegacyRoot {
173
+ readonly root: string;
174
+ readonly identity: LegacyEntrySnapshot;
175
+ }
176
+
177
+ async function canonicalLegacyRoot(artifactsDir: string): Promise<CanonicalLegacyRoot> {
178
+ const lexicalRoot = path.resolve(artifactsDir, "local");
179
+ const lexicalIdentity = await snapshotDirectory(lexicalRoot, "");
180
+ const [canonicalArtifactsDir, canonicalRoot] = await Promise.all([
181
+ fs.realpath(path.resolve(artifactsDir)),
182
+ fs.realpath(lexicalRoot),
183
+ ]);
184
+ if (canonicalRoot === canonicalArtifactsDir) throw new Error("Unsafe legacy local:// migration source");
185
+ ensureWithinRoot(canonicalRoot, canonicalArtifactsDir);
186
+ const canonicalIdentity = await snapshotDirectory(canonicalRoot, "");
187
+ if (!matchesSnapshot(canonicalIdentity, lexicalIdentity))
188
+ throw new Error("Legacy local:// migration source changed during capture");
189
+ return { root: canonicalRoot, identity: lexicalIdentity };
190
+ }
191
+
172
192
  async function snapshotRegularFile(filePath: string, relativePath: string): Promise<LegacyEntrySnapshot> {
173
193
  const before = await fs.lstat(filePath, { bigint: true });
174
194
  if (!before.isFile() || before.isSymbolicLink()) throw new Error("Unsafe legacy local:// migration source");
@@ -202,11 +222,16 @@ function manifestsMatch(left: readonly LegacyEntrySnapshot[], right: readonly Le
202
222
  );
203
223
  }
204
224
 
205
- async function captureLegacyManifest(root: string): Promise<readonly LegacyEntrySnapshot[]> {
225
+ async function captureLegacyManifest(
226
+ root: string,
227
+ expectedRootIdentity: LegacyEntrySnapshot,
228
+ ): Promise<readonly LegacyEntrySnapshot[]> {
206
229
  const manifest: LegacyEntrySnapshot[] = [];
207
230
  let copiedBytes = 0n;
208
231
  const captureDirectory = async (directoryPath: string, relativePath: string): Promise<void> => {
209
232
  const directory = await snapshotDirectory(directoryPath, relativePath);
233
+ if (relativePath === "" && !matchesSnapshot(directory, expectedRootIdentity))
234
+ throw new Error("Legacy local:// migration source changed during capture");
210
235
  manifest.push(directory);
211
236
  const entries = (await fs.readdir(directoryPath, { withFileTypes: true })).sort((a, b) =>
212
237
  a.name.localeCompare(b.name),
@@ -384,10 +409,11 @@ async function migrateLegacyLocal(
384
409
  await fs.writeFile(marker, "absent\n", { mode: 0o600, flag: "wx" });
385
410
  return;
386
411
  }
387
- const legacyRoot = path.resolve(artifactsDir, "local");
412
+ let legacySource: CanonicalLegacyRoot;
388
413
  let manifest: readonly LegacyEntrySnapshot[];
389
414
  try {
390
- manifest = await captureLegacyManifest(legacyRoot);
415
+ legacySource = await canonicalLegacyRoot(artifactsDir);
416
+ manifest = await captureLegacyManifest(legacySource.root, legacySource.identity);
391
417
  } catch (error) {
392
418
  if (isEnoent(error)) {
393
419
  await fs.writeFile(marker, "absent\n", { mode: 0o600, flag: "wx" });
@@ -397,8 +423,8 @@ async function migrateLegacyLocal(
397
423
  }
398
424
  const staging = path.join(scratchParent, `.gjc-local-migration-${randomUUID()}`);
399
425
  try {
400
- await copyLegacyManifest(legacyRoot, staging, manifest);
401
- const verifiedManifest = await captureLegacyManifest(legacyRoot);
426
+ await copyLegacyManifest(legacySource.root, staging, manifest);
427
+ const verifiedManifest = await captureLegacyManifest(legacySource.root, legacySource.identity);
402
428
  if (!manifestsMatch(verifiedManifest, manifest))
403
429
  throw new Error("Legacy local:// migration source changed during capture");
404
430
  for (const entry of await fs.readdir(staging)) {
@@ -410,7 +436,7 @@ async function migrateLegacyLocal(
410
436
  }
411
437
  await fs.rename(path.join(staging, entry), path.join(localRoot, entry));
412
438
  }
413
- await retireLegacyTree(legacyRoot, manifest);
439
+ await retireLegacyTree(legacySource.root, manifest);
414
440
  await fs.writeFile(marker, "verified\n", { mode: 0o600, flag: "wx" });
415
441
  } finally {
416
442
  await fs.rm(staging, { recursive: true, force: true });
package/src/main.ts CHANGED
@@ -12,6 +12,8 @@ import { createInterface } from "node:readline/promises";
12
12
  import type { ImageContent } from "@gajae-code/ai";
13
13
  import {
14
14
  $env,
15
+ $pickenv,
16
+ getAgentDir,
15
17
  getProjectDir,
16
18
  logger,
17
19
  normalizePathForComparison,
@@ -20,7 +22,6 @@ import {
20
22
  VERSION,
21
23
  } from "@gajae-code/utils";
22
24
  import chalk from "chalk";
23
- import { runCli } from "./cli";
24
25
  import type { Args } from "./cli/args";
25
26
  import { processFileArguments } from "./cli/file-processor";
26
27
  import { buildInitialMessage } from "./cli/initial-message";
@@ -535,7 +536,16 @@ interface InteractiveModeFactoryOptions {
535
536
  type CreateInteractiveMode = (options: InteractiveModeFactoryOptions) => InteractiveMode;
536
537
 
537
538
  type ResumePickerTerminalCheck = () => boolean;
538
- type ListForResumePickerReadOnly = (cwd: string, sessionDir?: string) => Promise<SessionInfo[]>;
539
+ type ListForResumePickerReadOnly = (cwd: string, sessionDir?: string, storage?: undefined) => Promise<SessionInfo[]>;
540
+
541
+ type ListManagedForResumePickerReadOnly = (
542
+ cwd: string,
543
+ managedAgentDir?: string,
544
+ storage?: undefined,
545
+ ) => Promise<SessionInfo[]>;
546
+
547
+ type ResolveManagedAgentDirForScope = (cwd: string) => string;
548
+
539
549
  type SelectResumeSession = (sessions: SessionInfo[]) => Promise<SessionSelectionResult>;
540
550
  type OpenExistingSessionStrict = (
541
551
  identity: ResumeSessionIdentity,
@@ -547,6 +557,11 @@ type OpenExistingSessionStrict = (
547
557
  export const BARE_RESUME_CONFLICT_ERROR =
548
558
  "--resume without a session cannot be combined with --continue, --fork, or --no-session.";
549
559
  export const BARE_RESUME_INTERACTIVE_ERROR = "--resume requires an interactive terminal; use --resume <id>.";
560
+
561
+ /** Resolves only the managed agent root needed for pre-consent picker inventory. */
562
+ export function resolveManagedAgentDirForScope(_cwd: string): string {
563
+ return getAgentDir();
564
+ }
550
565
  export const BARE_RESUME_OPEN_ERROR = "Could not open the selected session. Use --resume <id>.";
551
566
 
552
567
  function isBareResume(parsed: Args): boolean {
@@ -742,7 +757,13 @@ export async function createSessionManager(
742
757
  if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) {
743
758
  return await SessionManager.forkFrom(forkSource, cwd, sessionDestination(), undefined, migrationPolicy);
744
759
  }
745
- const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir);
760
+ const match = await resolveResumableSession(
761
+ forkSource,
762
+ cwd,
763
+ parsed.sessionDir,
764
+ undefined,
765
+ parsed.sessionDir ? undefined : activeSettings.getAgentDir(),
766
+ );
746
767
  if (!match) {
747
768
  throw new Error(`Session "${forkSource}" not found.`);
748
769
  }
@@ -755,14 +776,18 @@ export async function createSessionManager(
755
776
  if (typeof parsed.resume === "string") {
756
777
  const sessionArg = parsed.resume;
757
778
  if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
758
- return await SessionManager.open(
759
- sessionArg,
760
- SessionManager.explicitDestination(path.dirname(sessionArg)),
761
- undefined,
762
- migrationPolicy,
763
- );
779
+ const destination = parsed.sessionDir
780
+ ? SessionManager.explicitDestination(parsed.sessionDir)
781
+ : SessionManager.explicitDestination(path.dirname(sessionArg));
782
+ return await SessionManager.open(sessionArg, destination, undefined, migrationPolicy);
764
783
  }
765
- const match = await resolveResumableSession(sessionArg, cwd, parsed.sessionDir);
784
+ const match = await resolveResumableSession(
785
+ sessionArg,
786
+ cwd,
787
+ parsed.sessionDir,
788
+ undefined,
789
+ parsed.sessionDir ? undefined : activeSettings.getAgentDir(),
790
+ );
766
791
  if (!match) {
767
792
  throw new Error(`Session "${sessionArg}" not found.`);
768
793
  }
@@ -1059,12 +1084,41 @@ export interface RunRootCommandDependencies {
1059
1084
  runPrintMode?: RunPrintMode;
1060
1085
  isResumePickerTerminal?: ResumePickerTerminalCheck;
1061
1086
  listForResumePickerReadOnly?: ListForResumePickerReadOnly;
1087
+ listManagedForResumePickerReadOnly?: ListManagedForResumePickerReadOnly;
1088
+ resolveManagedAgentDirForScope?: ResolveManagedAgentDirForScope;
1062
1089
  selectResumeSession?: SelectResumeSession;
1063
1090
  openExistingSessionStrict?: OpenExistingSessionStrict;
1064
1091
  initializeSettings?: typeof Settings.init;
1065
1092
  loadSettingsForScope?: typeof Settings.loadForScope;
1066
1093
  }
1067
1094
 
1095
+ export interface ModelRoleOverrides {
1096
+ smol?: string;
1097
+ slow?: string;
1098
+ plan?: string;
1099
+ }
1100
+
1101
+ /**
1102
+ * Resolve the ephemeral `smol`/`slow`/`plan` model-role overrides.
1103
+ *
1104
+ * Precedence per role is CLI flag > documented `GJC_*_MODEL` > legacy
1105
+ * `PI_*_MODEL`, matching the repo-wide GJC-first/PI-fallback convention.
1106
+ * Resolution reads the process environment via `$pickenv`, which trims values
1107
+ * and treats empty/whitespace as unset; it is deliberately kept separate from
1108
+ * credential env resolution (`$credentialEnv`/`$pickCredentialEnv`). The
1109
+ * function is pure and stateless, so it reads fresh each call and a later
1110
+ * invocation never inherits an earlier one's values.
1111
+ */
1112
+ export function resolveModelRoleOverrides(parsed: Pick<Args, "smol" | "slow" | "plan">): ModelRoleOverrides {
1113
+ const overrides: ModelRoleOverrides = {};
1114
+ const smol = parsed.smol ?? $pickenv("GJC_SMOL_MODEL", "PI_SMOL_MODEL");
1115
+ const slow = parsed.slow ?? $pickenv("GJC_SLOW_MODEL", "PI_SLOW_MODEL");
1116
+ const plan = parsed.plan ?? $pickenv("GJC_PLAN_MODEL", "PI_PLAN_MODEL");
1117
+ if (smol) overrides.smol = smol;
1118
+ if (slow) overrides.slow = slow;
1119
+ if (plan) overrides.plan = plan;
1120
+ return overrides;
1121
+ }
1068
1122
  export async function runRootCommand(
1069
1123
  parsed: Args,
1070
1124
  rawArgs: string[],
@@ -1095,10 +1149,18 @@ export async function runRootCommand(
1095
1149
  await logger.time("maybeAutoChdir", maybeAutoChdir, parsedArgs);
1096
1150
  autoChdirApplied = true;
1097
1151
  const resumeCwd = getProjectDir();
1098
- const sessions = await (deps.listForResumePickerReadOnly ?? SessionManager.listForResumePickerReadOnly)(
1099
- resumeCwd,
1100
- parsedArgs.sessionDir,
1101
- );
1152
+ const managedAgentDir = parsedArgs.sessionDir
1153
+ ? undefined
1154
+ : (deps.resolveManagedAgentDirForScope ?? resolveManagedAgentDirForScope)(resumeCwd);
1155
+ const sessions = parsedArgs.sessionDir
1156
+ ? await (deps.listForResumePickerReadOnly ?? SessionManager.listForResumePickerReadOnly)(
1157
+ resumeCwd,
1158
+ parsedArgs.sessionDir,
1159
+ )
1160
+ : await (deps.listManagedForResumePickerReadOnly ?? SessionManager.listManagedForResumePickerReadOnly)(
1161
+ resumeCwd,
1162
+ managedAgentDir,
1163
+ );
1102
1164
  if (sessions.length === 0) {
1103
1165
  process.stdout.write(`${chalk.dim("No sessions found")}\n`);
1104
1166
  return;
@@ -1109,19 +1171,16 @@ export async function runRootCommand(
1109
1171
  if (selection.kind === "cancelled") {
1110
1172
  return;
1111
1173
  }
1174
+ const scopedSettings = await (deps.loadSettingsForScope ?? Settings.loadForScope)({ cwd: resumeCwd });
1112
1175
  const resumeMigrationPolicy =
1113
- (await (deps.loadSettingsForScope ?? Settings.loadForScope)({ cwd: resumeCwd })).get(
1114
- "session.directoryMigration",
1115
- ) === "disabled"
1116
- ? "disabled"
1117
- : "copy-retain";
1176
+ scopedSettings.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain";
1118
1177
  let opened: StrictSessionOpenResult;
1119
1178
  try {
1120
1179
  opened = await (deps.openExistingSessionStrict ?? SessionManager.openExistingStrict)(
1121
1180
  selection.identity,
1122
1181
  parsedArgs.sessionDir
1123
1182
  ? SessionManager.explicitDestination(parsedArgs.sessionDir)
1124
- : SessionManager.managedDestination(resumeCwd, settings.getAgentDir()),
1183
+ : SessionManager.managedDestination(resumeCwd, scopedSettings.getAgentDir()),
1125
1184
  undefined,
1126
1185
  resumeMigrationPolicy,
1127
1186
  );
@@ -1253,15 +1312,14 @@ export async function runRootCommand(
1253
1312
  // Initialize discovery system with settings for provider persistence
1254
1313
  logger.time("initializeWithSettings", initializeWithSettings, settingsInstance);
1255
1314
 
1256
- // Apply model role overrides from CLI args or env vars (ephemeral, not persisted)
1257
- const smolModel = parsedArgs.smol ?? $env.PI_SMOL_MODEL;
1258
- const slowModel = parsedArgs.slow ?? $env.PI_SLOW_MODEL;
1259
- const planModel = parsedArgs.plan ?? $env.PI_PLAN_MODEL;
1260
- if (smolModel || slowModel || planModel) {
1315
+ // Apply model role overrides from CLI args or env vars (ephemeral, not persisted).
1316
+ // Precedence per role: CLI flag > documented GJC_*_MODEL > legacy PI_*_MODEL.
1317
+ const roleOverrides = resolveModelRoleOverrides(parsedArgs);
1318
+ if (roleOverrides.smol || roleOverrides.slow || roleOverrides.plan) {
1261
1319
  settingsInstance.overrideModelRoles({
1262
- smol: smolModel,
1263
- slow: slowModel,
1264
- plan: planModel,
1320
+ ...(roleOverrides.smol ? { smol: roleOverrides.smol } : {}),
1321
+ ...(roleOverrides.slow ? { slow: roleOverrides.slow } : {}),
1322
+ ...(roleOverrides.plan ? { plan: roleOverrides.plan } : {}),
1265
1323
  });
1266
1324
  }
1267
1325
 
@@ -1629,5 +1687,6 @@ export async function main(args: string[]): Promise<void> {
1629
1687
  await completeManagedOwnerRecovery(admission.context);
1630
1688
  return;
1631
1689
  }
1690
+ const { runCli } = await import("./cli");
1632
1691
  await runCli(args.length === 0 ? ["launch"] : args);
1633
1692
  }
@@ -49,7 +49,7 @@ export const APP_ACTION_METADATA: readonly ActionMetadata[] = [
49
49
  action("app.clipboard.copyPrompt", "Copy prompt", "Clipboard", ["composer"]),
50
50
  action("app.session.new", "New session", "Session", ["composer"]),
51
51
  action("app.session.tree", "Session tree", "Session", ["composer"]),
52
- action("app.session.fork", "Fork session", "Session", ["composer"]),
52
+ action("app.session.fork", "Branch from message", "Session", ["composer"]),
53
53
  action("app.session.resume", "Resume session", "Session", ["composer"]),
54
54
  action("app.session.observe", "Observe sessions", "Session", ["composer"]),
55
55
  action("app.session.dashboard", "Show sessions dashboard", "Session", ["composer"]),
@@ -1,4 +1,10 @@
1
1
  import { type Component, Container, Markdown, Spacer, Text, type TUI } from "@gajae-code/tui";
2
+ import {
3
+ BTW_MAX_ANSWER_UTF8_BYTES,
4
+ BTW_MAX_CONTEXT_TURNS,
5
+ sanitizeBtwError,
6
+ truncateUtf8,
7
+ } from "../../session/btw-contract";
2
8
  import { replaceTabs } from "../../tools/render-utils";
3
9
  import { getMarkdownTheme, theme } from "../theme/theme";
4
10
  import { DynamicBorder } from "./dynamic-border";
@@ -10,12 +16,21 @@ interface BtwPanelComponentOptions {
10
16
  tui: TUI;
11
17
  }
12
18
 
19
+ interface BtwTurn {
20
+ question: string;
21
+ answer: string;
22
+ state: BtwPanelState;
23
+ errorMessage?: string;
24
+ }
25
+
13
26
  export class BtwPanelComponent extends Container {
14
27
  #question: string;
15
28
  #tui: TUI;
16
29
  #state: BtwPanelState = "running";
17
30
  #answer = "";
18
31
  #errorMessage: string | undefined;
32
+ #completedTurns: BtwTurn[] = [];
33
+ #streamingContent = new Container();
19
34
  #closed = false;
20
35
 
21
36
  constructor(options: BtwPanelComponentOptions) {
@@ -25,16 +40,29 @@ export class BtwPanelComponent extends Container {
25
40
  this.#rebuild();
26
41
  }
27
42
 
43
+ beginTurn(question: string): void {
44
+ if (this.#closed) return;
45
+ if (this.#state === "complete") {
46
+ this.#completedTurns.push(this.#currentTurn());
47
+ if (this.#completedTurns.length > BTW_MAX_CONTEXT_TURNS) this.#completedTurns.shift();
48
+ }
49
+ this.#question = question;
50
+ this.#answer = "";
51
+ this.#errorMessage = undefined;
52
+ this.#state = "running";
53
+ this.#rebuild();
54
+ }
55
+
28
56
  appendText(delta: string): void {
29
57
  if (!delta || this.#closed) return;
30
- this.#answer += delta;
31
- this.#rebuild();
58
+ this.#answer = truncateUtf8(this.#answer + delta, BTW_MAX_ANSWER_UTF8_BYTES);
59
+ this.#updateStreamingContent();
32
60
  }
33
61
 
34
62
  setAnswer(text: string): void {
35
63
  if (this.#closed) return;
36
- this.#answer = text;
37
- this.#rebuild();
64
+ this.#answer = truncateUtf8(text, BTW_MAX_ANSWER_UTF8_BYTES);
65
+ this.#updateStreamingContent();
38
66
  }
39
67
 
40
68
  markComplete(): void {
@@ -54,21 +82,33 @@ export class BtwPanelComponent extends Container {
54
82
  markError(message: string): void {
55
83
  if (this.#closed) return;
56
84
  this.#state = "error";
57
- this.#errorMessage = message;
85
+ this.#answer = "";
86
+ this.#errorMessage = sanitizeBtwError(message);
58
87
  this.#rebuild();
59
88
  }
60
89
 
61
90
  close(): void {
62
91
  this.#closed = true;
92
+ this.#question = "";
93
+ this.#completedTurns = [];
94
+ this.#answer = "";
95
+ this.#errorMessage = undefined;
96
+ this.#streamingContent.clear();
97
+ this.clear();
63
98
  }
64
99
 
65
100
  #rebuild(): void {
66
101
  this.clear();
67
102
  this.addChild(new DynamicBorder(str => theme.fg("dim", str)));
68
103
  this.addChild(new Spacer(1));
104
+ for (const turn of this.#completedTurns) {
105
+ this.addChild(this.#turnComponent(turn));
106
+ this.addChild(new Spacer(1));
107
+ }
69
108
  this.addChild(new Text(theme.fg("accent", replaceTabs(this.#question)), 1, 0));
70
109
  this.addChild(new Spacer(1));
71
- this.addChild(this.#contentComponent());
110
+ this.#updateStreamingContent(false);
111
+ this.addChild(this.#streamingContent);
72
112
  this.addChild(new Spacer(1));
73
113
  this.addChild(new Text(this.#footerLine(), 1, 0));
74
114
  this.addChild(new Spacer(1));
@@ -76,29 +116,55 @@ export class BtwPanelComponent extends Container {
76
116
  this.#tui.requestRender();
77
117
  }
78
118
 
119
+ #updateStreamingContent(requestRender = true): void {
120
+ this.#streamingContent.clear();
121
+ this.#streamingContent.addChild(this.#contentComponent(this.#state, this.#answer, this.#errorMessage));
122
+ if (requestRender) this.#tui.requestRender();
123
+ }
124
+
125
+ #currentTurn(): BtwTurn {
126
+ return {
127
+ question: this.#question,
128
+ answer: this.#answer,
129
+ state: this.#state,
130
+ errorMessage: this.#errorMessage,
131
+ };
132
+ }
133
+
134
+ #turnComponent(turn: BtwTurn): Component {
135
+ const container = new Container();
136
+ container.addChild(new Text(theme.fg("accent", replaceTabs(turn.question)), 1, 0));
137
+ container.addChild(new Spacer(1));
138
+ container.addChild(this.#contentComponent(turn.state, turn.answer, turn.errorMessage));
139
+ return container;
140
+ }
141
+
79
142
  #footerLine(): string {
80
143
  switch (this.#state) {
81
144
  case "running":
82
- return theme.fg("muted", "Esc cancel /btw");
145
+ return theme.fg("muted", "Esc cancel /btw and return to main chat");
83
146
  case "complete":
84
- return theme.fg("muted", "Esc dismiss");
147
+ return theme.fg("muted", "Type a follow-up · Esc return to main chat");
85
148
  case "aborted":
86
- return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`);
149
+ return theme.fg(
150
+ "warning",
151
+ `${theme.status.warning} Cancelled · Type a follow-up · Esc return to main chat`,
152
+ );
87
153
  case "error":
88
- return theme.fg("error", `${theme.status.error} Error · Esc dismiss`);
154
+ return theme.fg("error", `${theme.status.error} Error · Type a follow-up · Esc return to main chat`);
89
155
  }
90
156
  }
91
157
 
92
- #contentComponent(): Component {
93
- if (this.#state === "error") {
94
- return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 1, 0);
158
+ #contentComponent(state: BtwPanelState, answer: string, errorMessage: string | undefined): Component {
159
+ if (state === "error") {
160
+ return new Text(theme.fg("error", replaceTabs(errorMessage ?? "Unknown error")), 1, 0);
95
161
  }
96
- const text = replaceTabs(this.#answer).trim();
162
+ const text = replaceTabs(answer).trim();
97
163
  if (!text) {
98
- const waiting =
99
- this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned.";
164
+ const waiting = state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned.";
100
165
  return new Text(theme.fg("dim", waiting), 1, 0);
101
166
  }
167
+ if (state === "running") return new Text(text, 1, 0);
102
168
  return new Markdown(text, 1, 0, getMarkdownTheme());
103
169
  }
104
170
  }
@@ -35,6 +35,7 @@ import type { Settings } from "../../config/settings";
35
35
  import { type ThemeColor, theme } from "../../modes/theme/theme";
36
36
  import { formatModelOnboardingInlineHint } from "../../setup/model-onboarding-guidance";
37
37
  import { formatClampedModelSelector, getThinkingLevelMetadata, parseThinkingLevel } from "../../thinking";
38
+ import { getConfiguredImageModel } from "../../tools/image-gen";
38
39
  import { getTabBarTheme } from "../shared";
39
40
  import { DynamicBorder } from "./dynamic-border";
40
41
 
@@ -118,6 +119,9 @@ export type ModelSelectorSelection =
118
119
  | {
119
120
  kind: "deleteProfile";
120
121
  profileName: string;
122
+ }
123
+ | {
124
+ kind: "imageGeneration";
121
125
  };
122
126
 
123
127
  interface PendingThinkingChoice {
@@ -183,13 +187,18 @@ interface PresetBrowseRow {
183
187
  kind: "browse";
184
188
  }
185
189
 
190
+ interface PresetImageGenerationRow {
191
+ kind: "imageGeneration";
192
+ }
193
+
186
194
  type PresetLandingRow =
187
195
  | PresetGroupRow
188
196
  | PresetProfileRow
189
197
  | PresetCreateRow
190
198
  | PresetCreateUnavailableRow
191
199
  | PresetAlreadySavedRow
192
- | PresetBrowseRow;
200
+ | PresetBrowseRow
201
+ | PresetImageGenerationRow;
193
202
 
194
203
  // Stable logical identity for a preset landing row, independent of its current
195
204
  // list position. Used to relocate the cursor after the expanded group changes so
@@ -208,6 +217,8 @@ function presetRowIdentity(row: PresetLandingRow): string {
208
217
  return "createUnavailable";
209
218
  case "alreadySaved":
210
219
  return `alreadySaved:${row.profile.name}`;
220
+ case "imageGeneration":
221
+ return "imageGeneration";
211
222
  }
212
223
  }
213
224
 
@@ -962,6 +973,7 @@ export class ModelSelectorComponent extends Container {
962
973
  } else {
963
974
  rows.push({ kind: "createUnavailable", label: "Select a model before creating a custom preset" });
964
975
  }
976
+ rows.push({ kind: "imageGeneration" });
965
977
  rows.push({ kind: "browse" });
966
978
  return rows;
967
979
  }
@@ -1047,7 +1059,8 @@ export class ModelSelectorComponent extends Container {
1047
1059
  selected.kind === "browse" ||
1048
1060
  selected.kind === "create" ||
1049
1061
  selected.kind === "createUnavailable" ||
1050
- selected.kind === "alreadySaved"
1062
+ selected.kind === "alreadySaved" ||
1063
+ selected.kind === "imageGeneration"
1051
1064
  )
1052
1065
  return;
1053
1066
  if (this.#expandedPresetProviderId === selected.groupId) return;
@@ -1063,7 +1076,8 @@ export class ModelSelectorComponent extends Container {
1063
1076
  selected.kind === "browse" ||
1064
1077
  selected.kind === "create" ||
1065
1078
  selected.kind === "createUnavailable" ||
1066
- selected.kind === "alreadySaved"
1079
+ selected.kind === "alreadySaved" ||
1080
+ selected.kind === "imageGeneration"
1067
1081
  )
1068
1082
  return;
1069
1083
  if (this.#expandedPresetProviderId !== selected.groupId) return;
@@ -1162,6 +1176,16 @@ export class ModelSelectorComponent extends Container {
1162
1176
  this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
1163
1177
  continue;
1164
1178
  }
1179
+ if (row.kind === "imageGeneration") {
1180
+ const config = getConfiguredImageModel();
1181
+ const current =
1182
+ config && config.provider !== "auto"
1183
+ ? `${config.provider}${config.model ? ` (${config.model})` : ""}`
1184
+ : "Auto";
1185
+ const label = `Image Generation: ${current}`;
1186
+ this.#listContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
1187
+ continue;
1188
+ }
1165
1189
  if (row.kind === "group") {
1166
1190
  const authenticated = this.#isPresetGroupUsable(row.profiles);
1167
1191
  const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
@@ -1646,6 +1670,10 @@ export class ModelSelectorComponent extends Container {
1646
1670
  this.#switchToModelMode();
1647
1671
  return;
1648
1672
  }
1673
+ if (row.kind === "imageGeneration") {
1674
+ this.#onSelectCallback({ kind: "imageGeneration" });
1675
+ return;
1676
+ }
1649
1677
  if (row.kind === "group") {
1650
1678
  // A group is a list of alternative presets; only surface a login hint
1651
1679
  // when none of its members are usable. A partially-usable group stays
@@ -46,6 +46,7 @@ export interface StatusLineSettings {
46
46
  previewHighlightSegment?: StatusLineSegmentId;
47
47
  showHookStatus?: boolean;
48
48
  showSkillHud?: boolean;
49
+ showActionHints?: boolean;
49
50
  sessionAccent?: boolean;
50
51
  maxRows?: number;
51
52
  }
@@ -199,6 +200,7 @@ export class StatusLineComponent implements Component {
199
200
  separator: settings.get("statusLine.separator"),
200
201
  showHookStatus: settings.get("statusLine.showHookStatus"),
201
202
  showSkillHud: settings.get("statusLine.showSkillHud"),
203
+ showActionHints: settings.get("statusLine.showActionHints"),
202
204
  segmentOptions: settings.getGroup("statusLine").segmentOptions,
203
205
  sessionAccent: settings.get("statusLine.sessionAccent"),
204
206
  maxRows: settings.get("statusLine.maxRows"),
@@ -791,7 +793,10 @@ export class StatusLineComponent implements Component {
791
793
  }
792
794
 
793
795
  const right: string[] = [];
794
- const actionHints = getAvailableActionHints(this.#actionRegistry, this.#getKeybindings, width, this.#focusDomain);
796
+ const actionHints =
797
+ effectiveSettings.showActionHints === false
798
+ ? []
799
+ : getAvailableActionHints(this.#actionRegistry, this.#getKeybindings, width, this.#focusDomain);
795
800
  for (const segId of effectiveSettings.rightSegments) {
796
801
  const rendered = renderSegment(segId, ctx);
797
802
  if (rendered.visible && rendered.content) {