indusagi-coding-agent 0.2.12 → 0.2.14

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 (81) hide show
  1. package/.env.review.example +25 -0
  2. package/README.md +30 -0
  3. package/REVIEW_AGENT.md +137 -0
  4. package/dist/entry.js +1861 -979
  5. package/dist/guardrails.js +212 -36
  6. package/dist/index.js +2829 -1057
  7. package/dist/review-agent.js +4461 -0
  8. package/dist/review-server.js +4633 -0
  9. package/package.json +24 -7
  10. package/src/boot/auth-vault.ts +4 -5
  11. package/src/boot/contract.ts +1 -1
  12. package/src/boot/runners/addon-wiring.ts +111 -3
  13. package/src/boot/runners/benchmark-integrity.ts +107 -0
  14. package/src/boot/runners/repl-runner.ts +2 -1
  15. package/src/boot/runners/session.ts +26 -12
  16. package/src/boot/server-token.ts +20 -11
  17. package/src/boot/stages.ts +3 -6
  18. package/src/briefing/context-docs.ts +144 -12
  19. package/src/channels/oneshot.ts +6 -0
  20. package/src/conductor/catalog/catalog.ts +3 -1
  21. package/src/conductor/conductor.ts +77 -17
  22. package/src/conductor/contract.ts +9 -7
  23. package/src/conductor/index.ts +1 -1
  24. package/src/conductor/transcript-store/store.ts +4 -8
  25. package/src/console/auth-status.ts +9 -4
  26. package/src/console/components/AgentsView.ts +2 -2
  27. package/src/console/components/BackgroundAgents.ts +15 -2
  28. package/src/console/components/Banner.ts +20 -9
  29. package/src/console/components/Composer.ts +57 -82
  30. package/src/console/components/ScrollViewport.ts +46 -0
  31. package/src/console/components/TerminalConsole.ts +165 -162
  32. package/src/console/components/WorkingIndicator.ts +6 -5
  33. package/src/console/components/banner-sweep.ts +5 -1
  34. package/src/console/components/welcome.ts +3 -3
  35. package/src/console/contract.ts +10 -5
  36. package/src/console/index.ts +7 -7
  37. package/src/console/input/complete.ts +2 -2
  38. package/src/console/input/dir-reader.ts +3 -1
  39. package/src/console/input/graphemes.ts +22 -0
  40. package/src/console/input/keymap.ts +6 -4
  41. package/src/console/mount.ts +45 -3
  42. package/src/console/overlays/auth.ts +28 -8
  43. package/src/console/overlays/boards.ts +13 -40
  44. package/src/console/overlays/host.ts +10 -0
  45. package/src/console/overlays/pickers.ts +35 -30
  46. package/src/console/reducer.ts +12 -5
  47. package/src/console/slash/builtins.ts +4 -3
  48. package/src/console/slash/commands/integrations.ts +13 -14
  49. package/src/console/slash/commands/transcript.ts +3 -8
  50. package/src/console/slash/commands/workbench.ts +4 -4
  51. package/src/console/test-support.ts +48 -0
  52. package/src/index.ts +1 -0
  53. package/src/integrations/sarvam/config.ts +15 -13
  54. package/src/kit/external-editor.ts +17 -1
  55. package/src/kit/run-shell.ts +31 -0
  56. package/src/launch/catalog.ts +4 -2
  57. package/src/launch/contract.ts +1 -0
  58. package/src/launch/invocation/flags.ts +13 -1
  59. package/src/launch/invocation/read.ts +4 -3
  60. package/src/launch/login.ts +23 -10
  61. package/src/launch/oauth.ts +3 -0
  62. package/src/models.ts +166 -0
  63. package/src/review-agent/config.ts +75 -0
  64. package/src/review-agent/contract.ts +59 -0
  65. package/src/review-agent/github.ts +242 -0
  66. package/src/review-agent/index.ts +8 -0
  67. package/src/review-agent/prompt.ts +132 -0
  68. package/src/review-agent/reviewer.ts +118 -0
  69. package/src/review-agent/server.ts +221 -0
  70. package/src/review-agent/service.ts +165 -0
  71. package/src/review-agent/supabase.ts +209 -0
  72. package/src/review-agent/webhook.ts +76 -0
  73. package/src/security/private-state.ts +122 -0
  74. package/src/window-budget/summarize/condense.ts +4 -1
  75. package/src/workspace/brand.ts +1 -1
  76. package/src/workspace/locator.ts +2 -2
  77. package/supabase/migrations/20260827031803_indusagi_review_agent.sql +109 -0
  78. package/supabase/migrations/20260827035747_atomic_webhook_enqueue.sql +75 -0
  79. package/supabase/migrations/20260827040104_check_run_reconciliation.sql +8 -0
  80. package/supabase/migrations/20260827041049_owned_job_leases.sql +80 -0
  81. package/supabase/migrations/20260827042500_static_token_webhooks.sql +75 -0
@@ -46,10 +46,10 @@
46
46
  */
47
47
  import type { InkThemeAdapter, SessionSnapshot, StatusMessage, ToolExecutionState, UiDisplayBlock } from "indusagi/react-ink";
48
48
  import type { Dispatch, ReactNode, Reducer } from "react";
49
- import type { ConductorState, SessionConductor, SessionSignal } from "../conductor.js";
50
- import type { PreferenceStore } from "../settings.js";
51
- import type { SessionLibrary } from "../sessions.js";
52
- import type { AuthVault, LoginProvider, OAuthLoginResult } from "../launch.js";
49
+ import type { ConductorState, SessionConductor, SessionSignal } from "../conductor/index.js";
50
+ import type { PreferenceStore } from "../settings/index.js";
51
+ import type { SessionLibrary } from "../sessions/index.js";
52
+ import type { AuthVault, LoginProvider, OAuthLoginResult } from "../launch/index.js";
53
53
  import type { OAuthLoginCallbacks } from "indusagi/ai";
54
54
  /** Re-exported framework vocabulary that console consumers routinely need. */
55
55
  export type { SessionSnapshot, ToolExecutionState, StatusMessage, UiDisplayBlock, InkThemeAdapter, };
@@ -358,7 +358,11 @@ export interface ConsoleState {
358
358
  * - `busy:set` — set the in-flight flag.
359
359
  * - `tick` — bump the status-bar re-render tick.
360
360
  */
361
- export type ConsoleEvent = {
361
+ export type ConsoleEvent = { readonly type: "view:reset" } | {
362
+ readonly type: "display:set";
363
+ readonly key: "showReasoning" | "showImages";
364
+ readonly value: boolean;
365
+ } | {
362
366
  readonly type: "buffer:set";
363
367
  readonly text: string;
364
368
  } | {
@@ -485,6 +489,7 @@ export type SlashOutcome = {
485
489
  * with respect to React internals and lets tests pass a scripted fake.
486
490
  */
487
491
  export interface SlashContext {
492
+ readonly services?: OverlayServices;
488
493
  /** The raw argument string after the command name (may be empty). */
489
494
  readonly args: string;
490
495
  /** The session this console drives. */
@@ -22,13 +22,13 @@ export { consoleReducer, initConsoleState } from "./reducer.js";
22
22
  export { THEMES, THEME_SCHEMES, resolveTheme, MIDNIGHT_PALETTE, DAYLIGHT_PALETTE, PALETTES, deriveTokens, themeAdapter, frameworkColors, } from "./theme/index.js";
23
23
  export { SLASH_COMMANDS, FAMILY, DEFAULT_SLASH_REGISTRY, buildRegistry, tokensOf, findCommand, matchPrefix, listFamilies, commandsInFamily, SLASH_PREFIX, looksLikeSlash, parseSlash, resolveSlash, type SlashLine, type SlashResolution, } from "./slash/index.js";
24
24
  export { NO_INTENT, readKey, NO_CHORD, advanceChord, type KeyChord, type ConsoleVerb, type ConsoleIntent, type ChordOutcome, type ChordLatch, type ChordStep, PASTE_THRESHOLDS, PASTE_BURST_GAP_MS, EMPTY_PASTE_VAULT, pasteMarker, classifyPaste, coalescePaste, expandMarkers, hasMarkers, type PasteThresholds, type PasteVault, type PasteCapture, completeAt, applySuggestion, type CompletionKind, type Suggestion, type TokenSpan, type CompletionResult, type DirEntry, type DirReader, } from "./input/index.js";
25
- export { TerminalConsole } from "./components/TerminalConsole";
26
- export { Composer, type ComposerProps } from "./components/Composer";
27
- export { StatusBar, type StatusBarProps } from "./components/StatusBar";
28
- export { Banner, type BannerProps } from "./components/Banner";
29
- export { BackgroundAgents, deriveAgentRuns, type BackgroundAgentsProps, type AgentRun, type AgentRunStatus, } from "./components/BackgroundAgents";
30
- export { AgentsView, type AgentsViewProps } from "./components/AgentsView";
31
- export { MASCOTS, TIPS, WHATS_NEW, mascotRows, pickWelcomeVariant, randomWelcomeSeed, type MascotFace, type MascotTone, type MascotSegment, type WelcomeVariant, } from "./components/welcome";
25
+ export { TerminalConsole } from "./components/TerminalConsole.js";
26
+ export { Composer, type ComposerProps } from "./components/Composer.js";
27
+ export { StatusBar, type StatusBarProps } from "./components/StatusBar.js";
28
+ export { Banner, type BannerProps } from "./components/Banner.js";
29
+ export { BackgroundAgents, deriveAgentRuns, type BackgroundAgentsProps, type AgentRun, type AgentRunStatus, } from "./components/BackgroundAgents.js";
30
+ export { AgentsView, type AgentsViewProps } from "./components/AgentsView.js";
31
+ export { MASCOTS, TIPS, WHATS_NEW, mascotRows, pickWelcomeVariant, randomWelcomeSeed, type MascotFace, type MascotTone, type MascotSegment, type WelcomeVariant, } from "./components/welcome.js";
32
32
  export { gatherStartup, gatherChangelog } from "./startup.js";
33
33
  export type { StartupMap, StartupSection, StartupChangelog, StartupChangelogMode, StartupNotice, StartupNoticeKind, StartupInputs, } from "./startup.js";
34
34
  export { mountConsole, type MountConsoleOptions, type MountResult, } from "./mount.js";
@@ -76,13 +76,13 @@ function splitPathStem(stem: string) {
76
76
  const body = stem.slice(sigil.length);
77
77
  const slash = body.lastIndexOf("/");
78
78
  if (slash < 0) return { dir: ".", leaf: body, sigil };
79
- return { dir: body.slice(0, slash) || ".", leaf: body.slice(slash + 1), sigil };
79
+ return { dir: body.slice(0, slash) || "/", leaf: body.slice(slash + 1), sigil };
80
80
  }
81
81
 
82
82
  function completePath(stem: string, readDir: DirReader): Suggestion[] {
83
83
  const { dir, leaf, sigil } = splitPathStem(stem);
84
84
  const lowerLeaf = leaf.toLowerCase();
85
- const prefix = dir === "." ? "" : `${dir}/`;
85
+ const prefix = dir === "." ? "" : dir === "/" ? "/" : `${dir}/`;
86
86
  const matches = readDir(dir)
87
87
  .filter((e) => e.name.toLowerCase().startsWith(lowerLeaf))
88
88
  .sort((a, b) => {
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { readdirSync, statSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
+ import { homedir } from "node:os";
6
7
  import type { DirReader } from "./complete.js";
7
8
 
8
9
  function entryIsDir(base: string, name: string, isDirent: boolean, isSymlink: boolean) {
@@ -20,7 +21,8 @@ function entryIsDir(base: string, name: string, isDirent: boolean, isSymlink: bo
20
21
  */
21
22
  export function createDirReader(cwd: string = process.cwd()): DirReader {
22
23
  return (dirPath: string) => {
23
- const target = resolve(cwd, dirPath);
24
+ const expanded = dirPath === "~" ? homedir() : dirPath.startsWith("~/") ? resolve(homedir(), dirPath.slice(2)) : dirPath;
25
+ const target = resolve(cwd, expanded);
24
26
  try {
25
27
  const dirents = readdirSync(target, { withFileTypes: true });
26
28
  return dirents.map((d) => ({
@@ -0,0 +1,22 @@
1
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
2
+ export function previousGrapheme(text: string, at: number): number {
3
+ let previous = 0;
4
+ for (const part of segmenter.segment(text)) {
5
+ if (part.index >= at) break;
6
+ previous = part.index;
7
+ }
8
+ return previous;
9
+ }
10
+ export function nextGrapheme(text: string, at: number): number {
11
+ for (const part of segmenter.segment(text)) if (part.index > at) return part.index;
12
+ return text.length;
13
+ }
14
+ export function moveGrapheme(text: string, at: number, delta: number): number {
15
+ let next = Math.max(0, Math.min(at, text.length));
16
+ for (let i = 0; i < Math.abs(delta); i++) next = delta < 0 ? previousGrapheme(text, next) : nextGrapheme(text, next);
17
+ return next;
18
+ }
19
+ export function snapGrapheme(text: string, at: number): number {
20
+ const clamped = Math.max(0, Math.min(at, text.length));
21
+ return clamped === text.length ? clamped : previousGrapheme(text, clamped + 1);
22
+ }
@@ -52,6 +52,7 @@ export type ConsoleVerb =
52
52
 
53
53
  export interface ConsoleIntent {
54
54
  readonly verb: ConsoleVerb;
55
+ readonly direction?: -1 | 1;
55
56
  readonly text?: string;
56
57
  readonly overlay?: ModalKind;
57
58
  }
@@ -92,7 +93,7 @@ export function readKey(input: string, key: KeyChord): ConsoleIntent {
92
93
  case "n":
93
94
  return { verb: "flow:cycleModel" };
94
95
  case "p":
95
- return { verb: "flow:cycleModel" };
96
+ return { verb: "flow:cycleModel", direction: -1 };
96
97
  case "t":
97
98
  return { verb: "view:toggleReasoning" };
98
99
  case "v":
@@ -117,15 +118,16 @@ export function readKey(input: string, key: KeyChord): ConsoleIntent {
117
118
  }
118
119
  if (key.escape) return { verb: "flow:dismiss" };
119
120
  if (key.tab) return key.shift ? { verb: "mode:cyclePermission" } : { verb: "flow:accept" };
120
- if (key.backspace || key.delete || input === "\x7F" || input === "\b") {
121
+ if (key.delete) return { verb: "edit:eraseNext" };
122
+ if (key.backspace || input === "\x7F" || input === "\b") {
121
123
  return { verb: "edit:erasePrev" };
122
124
  }
123
125
  if (key.leftArrow) return { verb: "nav:left" };
124
126
  if (key.rightArrow) return { verb: "nav:right" };
125
127
  if (key.upArrow) return { verb: "nav:up" };
126
128
  if (key.downArrow) return { verb: "nav:down" };
127
- if (key.pageUp) return { verb: "nav:home" };
128
- if (key.pageDown) return { verb: "nav:end" };
129
+ // Page keys belong to the transcript/overlay viewport, not the composer.
130
+ if (key.pageUp || key.pageDown) return NO_INTENT;
129
131
  return isPrintable(input) ? typed(input) : NO_INTENT;
130
132
  }
131
133
 
@@ -8,6 +8,38 @@ import { DEFAULT_SLASH_REGISTRY } from "./slash/index.js";
8
8
  import { resolveTheme } from "./theme/index.js";
9
9
  import type { OverlayServices, SessionConductor, SlashRegistry } from "./contract.js";
10
10
 
11
+ export const SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
12
+ export const SYNC_OUTPUT_END = "\x1b[?2026l";
13
+
14
+ function supportsSynchronizedOutput(stream: NodeJS.WriteStream): boolean {
15
+ if (!stream.isTTY || process.env["INDUS_TUI_SYNC_OUTPUT"] === "0") return false;
16
+ const term = process.env["TERM"] ?? "";
17
+ return process.env["TMUX"] === undefined && process.env["STY"] === undefined &&
18
+ !term.startsWith("screen") && !term.startsWith("tmux");
19
+ }
20
+
21
+ /** Wrap complete Ink writes in DEC mode 2026 so terminals paint each frame atomically. */
22
+ export function synchronizedOutputStream(
23
+ stream: NodeJS.WriteStream,
24
+ enabled = supportsSynchronizedOutput(stream)
25
+ ): NodeJS.WriteStream {
26
+ if (!enabled) return stream;
27
+ const write = (chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void) => {
28
+ const framed = typeof chunk === "string"
29
+ ? `${SYNC_OUTPUT_BEGIN}${chunk}${SYNC_OUTPUT_END}`
30
+ : Buffer.concat([Buffer.from(SYNC_OUTPUT_BEGIN), Buffer.from(chunk), Buffer.from(SYNC_OUTPUT_END)]);
31
+ if (typeof encodingOrCallback === "function") return stream.write(framed, encodingOrCallback);
32
+ return stream.write(framed, encodingOrCallback, callback);
33
+ };
34
+ return new Proxy(stream, {
35
+ get(target, property) {
36
+ if (property === "write") return write;
37
+ const value = Reflect.get(target, property, target);
38
+ return typeof value === "function" ? value.bind(target) : value;
39
+ }
40
+ });
41
+ }
42
+
11
43
  /**
12
44
  * Options for {@link mountConsole}.
13
45
  */
@@ -50,7 +82,17 @@ export async function mountConsole(
50
82
  instance.unmount();
51
83
  }
52
84
  };
53
- const instance = render(createElement(TerminalConsole, props));
54
- await instance.waitUntilExit();
55
- return { clean: exited };
85
+ const wasRaw = process.stdin.isRaw === true;
86
+ const stdout = synchronizedOutputStream(process.stdout);
87
+ const instance = render(createElement(TerminalConsole, props), { exitOnCtrlC: false, stdout });
88
+ try {
89
+ await instance.waitUntilExit();
90
+ return { clean: exited };
91
+ } finally {
92
+ // Ink's passive input-hook cleanup may run after waitUntilExit resolves.
93
+ // Restore the terminal before handing control back to the caller.
94
+ instance.unmount();
95
+ if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw);
96
+ if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
97
+ }
56
98
  }
@@ -58,7 +58,7 @@ const SERVER_TIER_ACCOUNT_ID = "server-session";
58
58
  const SERVER_TIER_PROVIDER_ID = "indus-server";
59
59
 
60
60
  function SignOutDialog(props: OverlayGroupProps) {
61
- const { services, closeModal } = props;
61
+ const { services, closeModal, dispatch } = props;
62
62
  const [accounts, setAccounts] = useState<any[]>([]);
63
63
  useEffect(() => {
64
64
  let live = true;
@@ -112,8 +112,10 @@ function SignOutDialog(props: OverlayGroupProps) {
112
112
  } else {
113
113
  await services!.vault.remove(account.provider, account.accountId);
114
114
  }
115
- } catch {
116
- // ignore
115
+ await services!.refreshGatewayRouting();
116
+ dispatch({type:"status:set",status:{kind:"info",text:"Signed out. Model routing refreshed."}});
117
+ } catch (error) {
118
+ dispatch({type:"status:set",status:{kind:"error",text:error instanceof Error ? error.message : "Sign-out failed."}});
117
119
  }
118
120
  closeModal();
119
121
  }
@@ -155,15 +157,22 @@ function OAuthFlowDialog(props: OverlayGroupProps & { providerId: string; mode:
155
157
  })();
156
158
  const [state, setState] = useState(() => seedOAuthState(providerId, providerName, mode));
157
159
  const pending = useRef<((value: string) => void) | null>(null);
160
+ const cancelPrompt = useRef<(() => void) | null>(null);
161
+ const mounted = useRef(true);
162
+ const saving = useRef(false);
163
+ useEffect(() => () => { mounted.current = false; }, []);
158
164
  const merge = (next: any) => setState((prev) => ({ ...prev, ...next }));
159
165
  useEffect(() => {
160
166
  if (mode !== "oauth") return;
161
167
  let live = true;
168
+ const controller = new AbortController();
162
169
  const safeMerge = (next: any) => {
163
170
  if (live) merge(next);
164
171
  };
165
172
  const callbacks = {
173
+ signal: controller.signal,
166
174
  onAuth: (info: any) => {
175
+ if (!live) return;
167
176
  safeMerge({ authInfo: info, progress: "Opening your browser to sign in…" });
168
177
  void services!.openLoginUrl(info.url).then((opened: boolean) => {
169
178
  safeMerge({
@@ -174,13 +183,17 @@ function OAuthFlowDialog(props: OverlayGroupProps & { providerId: string; mode:
174
183
  });
175
184
  },
176
185
  onPrompt: (prompt: any) =>
177
- new Promise<string>((resolve) => {
178
- pending.current = resolve;
186
+ new Promise<string>((resolve, reject) => {
187
+ if (!live) { reject(new DOMException("Sign-in cancelled", "AbortError")); return; }
188
+ cancelPrompt.current = () => reject(new DOMException("Sign-in cancelled", "AbortError"));
189
+ pending.current = value => { cancelPrompt.current = null; resolve(value); };
179
190
  safeMerge({ prompt, inputLabel: prompt.message, inputValue: "" });
180
191
  }),
181
192
  onManualCodeInput: () =>
182
- new Promise<string>((resolve) => {
183
- pending.current = resolve;
193
+ new Promise<string>((resolve, reject) => {
194
+ if (!live) { reject(new DOMException("Sign-in cancelled", "AbortError")); return; }
195
+ cancelPrompt.current = () => reject(new DOMException("Sign-in cancelled", "AbortError"));
196
+ pending.current = value => { cancelPrompt.current = null; resolve(value); };
184
197
  safeMerge({
185
198
  prompt: { message: "Paste the authorization code or complete redirect URL:" },
186
199
  inputLabel: "Authorization code or redirect URL",
@@ -194,7 +207,9 @@ function OAuthFlowDialog(props: OverlayGroupProps & { providerId: string; mode:
194
207
  void (async () => {
195
208
  try {
196
209
  await services!.startOAuthLogin(providerId, callbacks, services!.vault);
210
+ if (!live) return;
197
211
  await services!.refreshGatewayRouting();
212
+ if (!live) return;
198
213
  selectProviderModel(services!, providerId);
199
214
  if (live) {
200
215
  dispatch({
@@ -209,15 +224,20 @@ function OAuthFlowDialog(props: OverlayGroupProps & { providerId: string; mode:
209
224
  })();
210
225
  return () => {
211
226
  live = false;
227
+ controller.abort(); cancelPrompt.current?.(); cancelPrompt.current = null; pending.current = null;
212
228
  };
213
229
  }, []);
214
230
  const saveApiKey = () => {
215
231
  const key = (state as any).inputValue.trim();
216
- if (key.length === 0) return;
232
+ if (key.length === 0 || saving.current) return;
233
+ saving.current = true;
217
234
  const account = (state as any).accountId ?? "default";
218
235
  void (async () => {
219
236
  try {
220
237
  await services!.vault.putApiKey(providerId, account, key, true);
238
+ if (!mounted.current) return;
239
+ await services!.refreshGatewayRouting();
240
+ if (!mounted.current) return;
221
241
  selectProviderModel(services!, providerId);
222
242
  dispatch({
223
243
  type: "status:set",
@@ -2,44 +2,17 @@
2
2
  * Saffron picker boards — the themed `/settings` and `/model` overlays.
3
3
  */
4
4
  import chalk from "chalk";
5
- import { readFileSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- import { join } from "node:path";
8
5
  import { useEffect, useMemo, useState } from "indusagi/react-host";
9
6
  import { Box, Text, useInput } from "indusagi/react-host/ink";
10
7
  import {
11
8
  matchesSearchQuery,
12
9
  wrapSelectionIndex,
13
10
  type InkThemeAdapter,
14
- type SettingsDialogItem,
15
- type Model
11
+ type SettingsDialogItem
16
12
  } from "indusagi/react-ink";
13
+ import type { Model } from "indusagi/ai";
17
14
  import { Fragment, jsx, jsxs } from "indusagi/react-host/jsx-runtime";
18
- import { isGatewayEligibleProvider } from "../../boot/runners/server-mode.js";
19
15
 
20
- function readServerTierInfo() {
21
- let signedIn = false;
22
- try {
23
- const file = join(homedir(), ".indusagi", "agent", "server-token.json");
24
- const token = JSON.parse(readFileSync(file, "utf8"));
25
- signedIn = typeof token.expires_at === "string" && new Date(token.expires_at).getTime() - Date.now() > 5 * 60 * 1e3;
26
- } catch {
27
- signedIn = false;
28
- }
29
- const localKeys = new Set<string>();
30
- try {
31
- const file = join(homedir(), ".indusagi", "agent", "auth.json");
32
- const vault = JSON.parse(readFileSync(file, "utf8"));
33
- for (const [provider, entry] of Object.entries(vault)) {
34
- if (entry !== null && typeof entry === "object" && Object.keys(entry).length > 0) {
35
- localKeys.add(provider);
36
- }
37
- }
38
- } catch {
39
- // ignore
40
- }
41
- return { signedIn, localKeys };
42
- }
43
16
 
44
17
  function truncate(text: string, width: number): string {
45
18
  if (text.length <= width) return text;
@@ -155,6 +128,7 @@ export interface SettingsBoardProps {
155
128
  }
156
129
 
157
130
  export function SettingsBoard({ theme, items, onClose }: SettingsBoardProps) {
131
+ const [error, setError] = useState<string>();
158
132
  const [query, setQuery] = useState("");
159
133
  const [selectedIndex, setSelectedIndex] = useState(0);
160
134
  const filtered = useMemo(
@@ -172,7 +146,11 @@ export function SettingsBoard({ theme, items, onClose }: SettingsBoardProps) {
172
146
  const item = filtered[selectedIndex];
173
147
  if (!item) return;
174
148
  const value = nextValue(item.values, item.value, direction);
175
- if (value && value !== item.value) void item.onChange(value);
149
+ if (value && value !== item.value) {
150
+ setError(undefined);
151
+ const failed = (error: unknown) => setError(error instanceof Error ? error.message : String(error));
152
+ try { void Promise.resolve(item.onChange(value)).catch(failed); } catch (error) { failed(error); }
153
+ }
176
154
  };
177
155
  useInput((input, key) => {
178
156
  if (key.escape) return onClose();
@@ -187,7 +165,7 @@ export function SettingsBoard({ theme, items, onClose }: SettingsBoardProps) {
187
165
  const { start, budget, below } = windowAround(filtered.length, selectedIndex);
188
166
  const visible = filtered.slice(start, start + budget);
189
167
  const selected = filtered[selectedIndex];
190
- const detail = selected ? `${selected.description} · ${selected.values.join(" / ")}` : void 0;
168
+ const detail = error ?? (selected ? `${selected.description} · ${selected.values.join(" / ")}` : void 0);
191
169
  const list =
192
170
  items.length === 0
193
171
  ? jsx(Text, { children: theme.muted("No settings are available.") })
@@ -242,13 +220,8 @@ export function ModelBoard({ theme, models, onClose, onSelect, search }: ModelBo
242
220
  const [query, setQuery] = useState(search ?? "");
243
221
  const [selectedIndex, setSelectedIndex] = useState(0);
244
222
  useEffect(() => setQuery(search ?? ""), [search]);
245
- const serverTier = useMemo(readServerTierInfo, []);
246
- const isServerTier = (provider: string) =>
247
- serverTier.signedIn && isGatewayEligibleProvider(provider) && !serverTier.localKeys.has(provider);
248
- const available = useMemo(
249
- () => models.filter((model) => serverTier.localKeys.has(model.provider) || isServerTier(model.provider)),
250
- [models, serverTier]
251
- );
223
+ // Authentication belongs to the injected service/parent, never a guessed HOME path.
224
+ const available = models;
252
225
  const filtered = useMemo(() => available.filter((model) => modelMatches(model, query)), [available, query]);
253
226
  useEffect(() => setSelectedIndex(0), [query]);
254
227
  useEffect(() => {
@@ -289,10 +262,10 @@ export function ModelBoard({ theme, models, onClose, onSelect, search }: ModelBo
289
262
  jsx(Box, { width: 2, children: jsx(Text, { children: isSelected ? theme.color("accent", "›") : " " }) }),
290
263
  jsx(Text, { bold: isSelected, children: theme.color("text", model.id) }),
291
264
  model.name ? jsx(Text, { children: theme.dim(` ${model.name}`) }) : null,
292
- isServerTier(model.provider) ? jsx(Text, { children: theme.color("accent", " ⫡ server") }) : null
265
+ jsx(Text, { children: theme.dim(` ${model.provider}`) })
293
266
  ]
294
267
  },
295
- model.id
268
+ `${model.provider}/${model.id}`
296
269
  );
297
270
  })
298
271
  });
@@ -16,8 +16,18 @@ export interface OverlayHostProps {
16
16
  readonly closeModal: () => void;
17
17
  }
18
18
 
19
+ import { Box, Text, useInput } from "indusagi/react-host/ink";
20
+
21
+ function UnavailableOverlay({ closeModal }: { closeModal: () => void }) {
22
+ useInput((_input, key) => { if (key.escape || key.return) closeModal(); });
23
+ return jsx(Box, {children: jsx(Text, {children: "This dialog is unavailable in this host. Esc / Enter to return."})});
24
+ }
25
+
19
26
  export function OverlayHost(props: OverlayHostProps) {
20
27
  if (props.modal.kind === "none") return null;
28
+ if (!props.services && props.modal.kind !== "approval" && props.modal.kind !== "plugin") {
29
+ return jsx(UnavailableOverlay, {closeModal: props.closeModal});
30
+ }
21
31
  const forwarded = {
22
32
  modal: props.modal,
23
33
  services: props.services,
@@ -2,7 +2,10 @@
2
2
  * Picker overlays — the selection dialogs raised over the transcript.
3
3
  */
4
4
  import { useEffect, useState } from "indusagi/react-host";
5
- import { ScopedModelsDialog, ThemeDialog, type Model } from "indusagi/react-ink";
5
+ import { ScopedModelsDialog, ThemeDialog } from "indusagi/react-ink";
6
+ import type { Model } from "indusagi/ai";
7
+ import type { ThemeScheme } from "../contract.js";
8
+ import type { Preferences, SettingKey } from "../../settings/contract.js";
6
9
  import { jsx } from "indusagi/react-host/jsx-runtime";
7
10
  import type { ConsoleDispatch, InkThemeAdapter, ModalState, OverlayServices } from "../contract.js";
8
11
  import { DEFAULT_SCHEME, isThemeScheme } from "../contract.js";
@@ -13,6 +16,15 @@ import { ModelBoard, SettingsBoard } from "./boards.js";
13
16
 
14
17
  const TOGGLE_VALUES = ["on", "off"];
15
18
 
19
+ function saveSetting<K extends SettingKey>(settings: OverlayServices["settings"], key: K, value: Preferences[K]) {
20
+ const previous = settings.get(key);
21
+ try { settings.set(key, value); settings.save(); }
22
+ catch (error) { settings.set(key, previous); throw error; }
23
+ }
24
+ function reportError(dispatch: ConsoleDispatch, error: unknown) {
25
+ dispatch({type:"status:set",status:{kind:"error",text:error instanceof Error ? error.message : String(error)}});
26
+ }
27
+
16
28
  function refToModel(ref: any): Model<any> {
17
29
  return {
18
30
  id: ref.id,
@@ -37,7 +49,7 @@ function listModels(services: OverlayServices): Model<any>[] {
37
49
  }
38
50
 
39
51
  function ModelPicker(props: OverlayGroupProps) {
40
- const { services, theme, closeModal } = props;
52
+ const { services, theme, closeModal, dispatch } = props;
41
53
  const authed = useAuthenticatedProviders(services);
42
54
  if (!services) return null;
43
55
  const all = listModels(services);
@@ -48,8 +60,8 @@ function ModelPicker(props: OverlayGroupProps) {
48
60
  onSelect: (model: Model<any>) => {
49
61
  try {
50
62
  services.conductor.selectModel(model.id);
51
- } catch {
52
- // ignore
63
+ } catch (error) {
64
+ reportError(dispatch, error);
53
65
  }
54
66
  closeModal();
55
67
  },
@@ -72,8 +84,7 @@ function ScopedModelPicker(props: OverlayGroupProps) {
72
84
  useEffect(() => {
73
85
  if (!services || payload.intent !== "reset") return;
74
86
  try {
75
- services.settings.set("enabledModels", []);
76
- services.settings.save();
87
+ saveSetting(services.settings, "enabledModels", []);
77
88
  dispatch({
78
89
  type: "status:set",
79
90
  status: { kind: "info", text: "Per-scope model overrides cleared." }
@@ -103,10 +114,9 @@ function ScopedModelPicker(props: OverlayGroupProps) {
103
114
  try {
104
115
  const all = models.map((model) => model.id);
105
116
  const next = ids.length === all.length ? [] : ids;
106
- services.settings.set("enabledModels", next);
107
- services.settings.save();
108
- } catch {
109
- // ignore
117
+ saveSetting(services.settings, "enabledModels", next);
118
+ } catch (error) {
119
+ reportError(dispatch, error);
110
120
  }
111
121
  closeModal();
112
122
  },
@@ -114,7 +124,7 @@ function ScopedModelPicker(props: OverlayGroupProps) {
114
124
  });
115
125
  }
116
126
 
117
- function currentScheme(services: OverlayServices): string {
127
+ function currentScheme(services: OverlayServices): ThemeScheme {
118
128
  try {
119
129
  const stored = services.settings.get("colourScheme");
120
130
  if (typeof stored === "string" && isThemeScheme(stored)) return stored;
@@ -136,12 +146,11 @@ function ThemePicker(props: OverlayGroupProps) {
136
146
  themes: [...THEME_SCHEMES],
137
147
  onSelect: (name: string) => {
138
148
  if (isThemeScheme(name)) {
139
- dispatch({ type: "scheme:set", scheme: name });
140
149
  try {
141
- services.settings.set("colourScheme", name);
142
- services.settings.save();
143
- } catch {
144
- // ignore
150
+ saveSetting(services.settings, "colourScheme", name);
151
+ dispatch({ type: "scheme:set", scheme: name });
152
+ } catch (error) {
153
+ reportError(dispatch, error);
145
154
  }
146
155
  }
147
156
  closeModal();
@@ -157,20 +166,19 @@ function SettingsPicker(props: OverlayGroupProps) {
157
166
  const [, setVersion] = useState(0);
158
167
  const bump = () => setVersion((n) => n + 1);
159
168
  const onOff = (flag: boolean) => (flag ? "on" : "off");
160
- const toggleRow = (id: string, key: string, label: string, description: string, live?: () => void) => ({
169
+ const toggleRow = (id: string, key: SettingKey, label: string, description: string, live?: (enabled: boolean) => void) => ({
161
170
  id,
162
171
  label,
163
172
  description,
164
173
  value: onOff(settings.get(key) as boolean),
165
174
  values: [...TOGGLE_VALUES],
166
175
  onChange: (value: string) => {
167
- settings.set(key, value === "on");
168
- settings.save();
169
- live?.();
176
+ saveSetting(settings, key, value === "on");
177
+ live?.(value === "on");
170
178
  bump();
171
179
  }
172
180
  });
173
- const deliveryRow = (id: string, key: string, label: string, description: string) => ({
181
+ const deliveryRow = (id: string, key: SettingKey, label: string, description: string) => ({
174
182
  id,
175
183
  label,
176
184
  description,
@@ -178,8 +186,7 @@ function SettingsPicker(props: OverlayGroupProps) {
178
186
  values: [...DELIVERY_MODES],
179
187
  onChange: (value: string) => {
180
188
  if (isDeliveryMode(value)) {
181
- settings.set(key, value);
182
- settings.save();
189
+ saveSetting(settings, key, value);
183
190
  }
184
191
  bump();
185
192
  }
@@ -193,16 +200,15 @@ function SettingsPicker(props: OverlayGroupProps) {
193
200
  value: settings.get("colourScheme") as string,
194
201
  values: [...THEME_SCHEMES],
195
202
  onChange: (value: string) => {
196
- settings.set("colourScheme", value);
197
- settings.save();
203
+ saveSetting(settings, "colourScheme", value);
198
204
  if (isThemeScheme(value)) dispatch({ type: "scheme:set", scheme: value });
199
205
  bump();
200
206
  }
201
207
  });
202
- items.push(toggleRow("show-images", "showImages", "Show images", "Render inline image content in the transcript.", () => dispatch({ type: "toggle:images" })));
208
+ items.push(toggleRow("show-images", "showImages", "Show images", "Render inline image content in the transcript.", value => dispatch({ type: "display:set", key: "showImages", value })));
203
209
  items.push(toggleRow("image-auto-resize", "imageAutoResize", "Auto-resize images", "Shrink oversized images to fit before handing them to a provider."));
204
210
  items.push(toggleRow("block-images", "blockImages", "Block images", "Withhold image content from providers entirely."));
205
- items.push(toggleRow("show-reasoning", "showReasoning", "Show reasoning", "Stream the model's reasoning / thinking text as it arrives.", () => dispatch({ type: "toggle:reasoning" })));
211
+ items.push(toggleRow("show-reasoning", "showReasoning", "Show reasoning", "Stream the model's reasoning / thinking text as it arrives.", value => dispatch({ type: "display:set", key: "showReasoning", value })));
206
212
  items.push(toggleRow("skill-commands", "enableSkillCommands", "Skill commands", "Surface discovered skills as their own slash commands."));
207
213
  items.push(deliveryRow("steering-mode", "steeringMode", "Steering mode", "Whether queued steering corrections release one at a time or all at once."));
208
214
  items.push(deliveryRow("follow-up-mode", "followUpMode", "Follow-up mode", "Whether queued follow-up prompts release one at a time or all at once."));
@@ -211,7 +217,7 @@ function SettingsPicker(props: OverlayGroupProps) {
211
217
  items.push(toggleRow("quiet-startup", "quietStartup", "Quiet startup", "Suppress the banner and tips shown on a normal interactive launch."));
212
218
  items.push(toggleRow("logo-sweep", "logoSweep", "Logo sweep", "Tint the startup wordmark and emblem along a static colour gradient."));
213
219
  items.push(toggleRow("reduced-motion", "reducedMotion", "Reduced motion", "Suppress motion-flavoured flourishes such as the logo colour sweep."));
214
- items.push(toggleRow("hardware-cursor", "showHardwareCursor", "Hardware cursor", "Reveal the terminal's own cursor instead of the software-drawn caret."));
220
+ // Ink 5 owns the hardware cursor; do not offer a nonfunctional switch.
215
221
  items.push({
216
222
  id: "editor-padding",
217
223
  label: "Editor padding",
@@ -221,8 +227,7 @@ function SettingsPicker(props: OverlayGroupProps) {
221
227
  onChange: (value: string) => {
222
228
  const parsed = Number.parseInt(value, 10);
223
229
  if (Number.isFinite(parsed)) {
224
- settings.set("editorPaddingX", parsed);
225
- settings.save();
230
+ saveSetting(settings, "editorPaddingX", parsed);
226
231
  }
227
232
  bump();
228
233
  }