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
@@ -1,19 +1,151 @@
1
- import { existsSync, readFileSync, realpathSync } from "node:fs";
2
- import { dirname, extname, isAbsolute, join, parse, resolve } from "node:path";
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, extname, isAbsolute, join, parse, resolve, sep } from "node:path";
3
3
  import type { ContextDoc } from "./contract.js";
4
4
 
5
- export interface GatherContextDocsOptions { readonly maxImportDepth?: number; readonly maxBytesPerDoc?: number; }
5
+ export interface GatherContextDocsOptions {
6
+ readonly maxImportDepth?: number;
7
+ readonly maxBytesPerDoc?: number;
8
+ /** Exact external files the user explicitly approved for this run. */
9
+ readonly approvedExternalPaths?: readonly string[];
10
+ }
11
+
6
12
  export const DEFAULT_MAX_IMPORT_DEPTH = 5;
7
13
  export const DEFAULT_MAX_BYTES_PER_DOC = 40_000;
8
14
  export const CANDIDATE_FILES = ["AGENTS.md", "CLAUDE.md", "INDUSAGI.md", "CLAUDE.local.md", "AGENTS.local.md", join(".indusagi", "CLAUDE.md"), join(".claude", "CLAUDE.md")];
9
- export const TEXT_FILE_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".text", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".csv", ".html", ".htm", ".css", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".py", ".rb", ".go", ".rs", ".sh", ".env"]);
15
+ export const TEXT_FILE_EXTENSIONS = new Set([".md", ".markdown", ".txt", ".text", ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".csv", ".html", ".htm", ".css", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".py", ".rb", ".go", ".rs", ".sh"]);
10
16
  export const IMPORT_REGEX = /(?:^|\s)@((?:[^\s\\]|\\ )+)/g;
11
17
 
12
- export function shortenPath(path: string, cwd: string, home: string): string { if (cwd && path.startsWith(cwd)) { const rest = path.slice(cwd.length).replace(/^[/\\]+/, ""); return rest ? `./${rest}` : "."; } return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path; }
13
- export function dirChain(cwd: string): string[] { const chain: string[] = []; let current = cwd; for (let i = 0; i < 256; i++) { chain.push(current); const parent = dirname(current); if (parent === current || current === parse(current).root) break; current = parent; } return chain.reverse(); }
14
- export function safeRealpath(path: string): string { try { return realpathSync(path); } catch { return path; } }
15
- export function isTextFile(path: string): boolean { const extension = extname(path).toLowerCase(); return !extension || TEXT_FILE_EXTENSIONS.has(extension); }
16
- export function readFileBody(path: string): string | null { if (!existsSync(path)) return null; try { const body = readFileSync(path, "utf8").trim(); return body || null; } catch { return null; } }
17
- export function extractImports(body: string, baseDir: string, home: string): string[] { const out: string[] = []; IMPORT_REGEX.lastIndex = 0; let match: RegExpExecArray | null; while ((match = IMPORT_REGEX.exec(body))) { let spec = match[1]; if (!spec) continue; const hash = spec.indexOf("#"); if (hash >= 0) spec = spec.slice(0, hash); spec = spec.replace(/\\ /g, " ").trim(); if (!spec) continue; const absolute = spec.startsWith("~/") ? join(home, spec.slice(2)) : isAbsolute(spec) ? spec : resolve(baseDir, spec); if (isTextFile(absolute)) out.push(absolute); } return out; }
18
- export function loadDocTree(path: string, cwd: string, home: string, seen: Set<string>, depth: number, maxDepth: number, maxBytes: number, out: ContextDoc[]): void { const real = safeRealpath(path); if (seen.has(real)) return; seen.add(real); const body = readFileBody(path); if (body === null) return; out.push({ path: shortenPath(path, cwd, home), body: body.length > maxBytes ? body.slice(0, maxBytes) : body }); if (depth >= maxDepth) return; for (const child of extractImports(body, dirname(path), home)) loadDocTree(child, cwd, home, seen, depth + 1, maxDepth, maxBytes, out); }
19
- export function gatherContextDocs(cwd: string, home: string, opts?: GatherContextDocsOptions): readonly ContextDoc[] { const out: ContextDoc[] = []; try { const seen = new Set<string>(); const roots = dirChain(cwd); if (home) roots.push(home); for (const dir of roots) for (const name of CANDIDATE_FILES) loadDocTree(join(dir, name), cwd, home, seen, 0, opts?.maxImportDepth ?? DEFAULT_MAX_IMPORT_DEPTH, opts?.maxBytesPerDoc ?? DEFAULT_MAX_BYTES_PER_DOC, out); } catch { return out; } return out; }
18
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
19
+ const SECRET_BASENAMES = new Set([
20
+ ".env", "credentials", "credentials.json", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
21
+ ]);
22
+ const SECRET_EXTENSIONS = new Set([".key", ".pem", ".p12", ".pfx"]);
23
+
24
+ export function shortenPath(path: string, cwd: string, home: string): string {
25
+ if (cwd && (path === cwd || path.startsWith(`${cwd}${sep}`))) {
26
+ const rest = path.slice(cwd.length).replace(/^[/\\]+/, "");
27
+ return rest ? `./${rest}` : ".";
28
+ }
29
+ return home && (path === home || path.startsWith(`${home}${sep}`)) ? `~${path.slice(home.length)}` : path;
30
+ }
31
+
32
+ /** Retained for API compatibility; context discovery no longer walks this chain. */
33
+ export function dirChain(cwd: string): string[] {
34
+ const chain: string[] = [];
35
+ let current = cwd;
36
+ for (let i = 0; i < 256; i++) {
37
+ chain.push(current);
38
+ const parent = dirname(current);
39
+ if (parent === current || current === parse(current).root) break;
40
+ current = parent;
41
+ }
42
+ return chain.reverse();
43
+ }
44
+
45
+ export function safeRealpath(path: string): string {
46
+ try { return realpathSync(path); } catch { return path; }
47
+ }
48
+
49
+ export function isTextFile(path: string): boolean {
50
+ const extension = extname(path).toLowerCase();
51
+ return !extension || TEXT_FILE_EXTENSIONS.has(extension);
52
+ }
53
+
54
+ export function isLikelySecretFile(path: string): boolean {
55
+ const name = basename(path).toLowerCase();
56
+ return SECRET_BASENAMES.has(name) || name.startsWith(".env.") || SECRET_EXTENSIONS.has(extname(name));
57
+ }
58
+
59
+ export function readFileBody(path: string): string | null {
60
+ let fd: number | undefined;
61
+ try {
62
+ const before = lstatSync(path);
63
+ if (before.isSymbolicLink() || !before.isFile()) return null;
64
+ fd = openSync(path, constants.O_RDONLY | NO_FOLLOW);
65
+ if (!fstatSync(fd).isFile()) return null;
66
+ const body = readFileSync(fd, "utf8").trim();
67
+ return body || null;
68
+ } catch {
69
+ return null;
70
+ } finally {
71
+ if (fd !== undefined) closeSync(fd);
72
+ }
73
+ }
74
+
75
+ export function extractImports(body: string, baseDir: string, home: string): string[] {
76
+ const out: string[] = [];
77
+ IMPORT_REGEX.lastIndex = 0;
78
+ let match: RegExpExecArray | null;
79
+ while ((match = IMPORT_REGEX.exec(body))) {
80
+ let spec = match[1];
81
+ if (!spec) continue;
82
+ const hash = spec.indexOf("#");
83
+ if (hash >= 0) spec = spec.slice(0, hash);
84
+ spec = spec.replace(/\\ /g, " ").trim();
85
+ if (!spec) continue;
86
+ const absolute = spec.startsWith("~/") ? join(home, spec.slice(2)) : isAbsolute(spec) ? spec : resolve(baseDir, spec);
87
+ if (isTextFile(absolute)) out.push(absolute);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function canonicalExistingFile(path: string): string | null {
93
+ try {
94
+ const before = lstatSync(path);
95
+ if (before.isSymbolicLink() || !before.isFile()) return null;
96
+ return realpathSync(path);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function isWithin(root: string, candidate: string): boolean {
103
+ return candidate === root || candidate.startsWith(`${root}${sep}`);
104
+ }
105
+
106
+ interface LoadPolicy {
107
+ readonly root: string;
108
+ readonly approvedExternal: ReadonlySet<string>;
109
+ }
110
+
111
+ function loadTrustedDocTree(path: string, cwd: string, home: string, seen: Set<string>, depth: number, maxDepth: number, maxBytes: number, out: ContextDoc[], policy: LoadPolicy): void {
112
+ if (isLikelySecretFile(path)) return;
113
+ const real = canonicalExistingFile(path);
114
+ if (real === null || seen.has(real) || isLikelySecretFile(real)) return;
115
+ if (!isWithin(policy.root, real) && !policy.approvedExternal.has(real)) return;
116
+ seen.add(real);
117
+ const body = readFileBody(real);
118
+ if (body === null) return;
119
+ out.push({ path: shortenPath(real, cwd, home), body: body.length > maxBytes ? body.slice(0, maxBytes) : body });
120
+ if (depth >= maxDepth) return;
121
+ for (const child of extractImports(body, dirname(real), home)) {
122
+ loadTrustedDocTree(child, cwd, home, seen, depth + 1, maxDepth, maxBytes, out, policy);
123
+ }
124
+ }
125
+
126
+ /** Compatibility wrapper with secure default confinement to cwd. */
127
+ export function loadDocTree(path: string, cwd: string, home: string, seen: Set<string>, depth: number, maxDepth: number, maxBytes: number, out: ContextDoc[]): void {
128
+ let root: string;
129
+ try { root = realpathSync(cwd); } catch { return; }
130
+ loadTrustedDocTree(path, root, home, seen, depth, maxDepth, maxBytes, out, { root, approvedExternal: new Set() });
131
+ }
132
+
133
+ export function gatherContextDocs(cwd: string, home: string, opts: GatherContextDocsOptions = {}): readonly ContextDoc[] {
134
+ const out: ContextDoc[] = [];
135
+ try {
136
+ const root = realpathSync(cwd);
137
+ const approvedExternal = new Set<string>();
138
+ for (const path of opts.approvedExternalPaths ?? []) {
139
+ const real = canonicalExistingFile(path);
140
+ if (real !== null && !isLikelySecretFile(real)) approvedExternal.add(real);
141
+ }
142
+ const policy = { root, approvedExternal };
143
+ const seen = new Set<string>();
144
+ for (const name of CANDIDATE_FILES) {
145
+ loadTrustedDocTree(join(root, name), root, home, seen, 0, opts.maxImportDepth ?? DEFAULT_MAX_IMPORT_DEPTH, opts.maxBytesPerDoc ?? DEFAULT_MAX_BYTES_PER_DOC, out, policy);
146
+ }
147
+ } catch {
148
+ return out;
149
+ }
150
+ return out;
151
+ }
@@ -49,6 +49,12 @@ function ndjsonStrategy(): OneshotStrategy {
49
49
  },
50
50
  onSignal(signal: unknown, ctx: ChannelContext): void {
51
51
  const s = signal as { kind: string };
52
+ // Running shell commands can emit thousands of rolling progress snapshots,
53
+ // each containing the same bounded output tail. Serializing every snapshot
54
+ // ignores stdout backpressure and can retain hundreds of megabytes. The
55
+ // terminal tool result is emitted in full by tool_end, which is the durable
56
+ // event required by one-shot consumers and benchmark trajectory conversion.
57
+ if (s.kind === "tool_update") return;
52
58
  ctx.out.write(ctx.framer.encodeLine({ type: "signal", name: s.kind, body: s }));
53
59
  },
54
60
  finish(state: ConductorState, ctx: ChannelContext): number {
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
- import { getModels, getProviders } from "indusagi/ai";
2
+ import { getModels, getProviders, modelRegistry } from "indusagi/ai";
3
3
  import type { KnownProvider, Model } from "indusagi/ai";
4
4
  import type { ModelCardRef } from "../contract.js";
5
+ import { applyCodingAgentModelOverlay } from "../../models.js";
5
6
 
6
7
  export interface CatalogCard {
7
8
  readonly id: string;
@@ -29,6 +30,7 @@ const rawModelGate = z.object({
29
30
  });
30
31
 
31
32
  const EXCLUDED_PROVIDERS = new Set(["mock"]);
33
+ applyCodingAgentModelOverlay(modelRegistry);
32
34
  const frameworkSource: CatalogSource = {
33
35
  providers: () => getProviders().filter((provider) => !EXCLUDED_PROVIDERS.has(String(provider))),
34
36
  models: (provider) => getModels(provider as KnownProvider),
@@ -3,8 +3,9 @@ import type { AgentEvent, AgentMessage, AgentTool, CanUseToolFn, ThinkingLevel }
3
3
  import { createZeroUsage } from "indusagi/ai";
4
4
  import type { Model, Usage } from "indusagi/ai";
5
5
  import * as path from "node:path";
6
+ import { runShell } from "../kit/run-shell.js";
6
7
  import { conductorFault } from "./contract.js";
7
- import type { ApprovalResolver, ConductorFault, ConductorPhase, ConductorState, PermissionMode, SessionConductor, SessionConductorOptions, SessionHead } from "./contract.js";
8
+ import type { ApprovalResolver, CodingAgentThinkingLevel, ConductorFault, ConductorPhase, ConductorState, PermissionMode, SessionConductor, SessionConductorOptions, SessionHead } from "./contract.js";
8
9
  import { ModelCatalog, ModelMatcher } from "./catalog/index.js";
9
10
  import type { CatalogCard } from "./catalog/index.js";
10
11
  import { SignalHub, translateAgentEvent } from "./signal-hub/index.js";
@@ -15,6 +16,7 @@ import { writePlan } from "../capability-deck/cards/plan-file.js";
15
16
  import { AUTO_CONDENSE_POLICY, isOverBudget } from "../window-budget/index.js";
16
17
  import { DiagnosticsEngine, formatDiagnosticsSummary, hasEslintConfig, hasTsConfig, isTypeScriptOrJs, runEslintDiagnostics, runTscDiagnostics } from "./diagnostics.js";
17
18
  import type { DiagnosticsConfig } from "./diagnostics.js";
19
+ import { isReasoningEffortSupported, type CodingAgentModel } from "../models.js";
18
20
 
19
21
  export interface AgentLike {
20
22
  subscribe(fn: (event: AgentEvent) => void): () => void;
@@ -27,7 +29,7 @@ export interface AgentLike {
27
29
  setTools(tools: AgentTool[]): void;
28
30
  setThinkingLevel?(level: ThinkingLevel): void;
29
31
  }
30
- export interface CondenseOpts { readonly force?: boolean; readonly model?: Model<any>; readonly contextTokens?: number; readonly signal?: AbortSignal; readonly apiKey?: string }
32
+ export interface CondenseOpts { readonly instructions?: string; readonly force?: boolean; readonly model?: Model<any>; readonly contextTokens?: number; readonly signal?: AbortSignal; readonly apiKey?: string }
31
33
  export type CondenseFn = (messages: AgentMessage[], opts?: CondenseOpts) => AgentMessage[] | Promise<AgentMessage[]>;
32
34
  export const noopCondense: CondenseFn = (messages) => messages;
33
35
  export interface RetryPolicy { readonly maxAttempts: number; readonly baseDelayMs: number }
@@ -62,7 +64,7 @@ export function reduceState(prev: ConductorState, action: StateAction): Conducto
62
64
  }
63
65
 
64
66
  const DEFAULT_RETRY = { maxAttempts: 2, baseDelayMs: 250 };
65
- const THINKING_LADDER: ThinkingLevel[] = ["off", "low", "medium", "high"];
67
+ const DEFAULT_THINKING_LADDER: readonly CodingAgentThinkingLevel[] = ["off", "low", "medium", "high"];
66
68
  const PERMISSION_CYCLE: PermissionMode[] = ["default", "acceptEdits", "plan", "bypass"];
67
69
 
68
70
  function newSessionId(): string { return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; }
@@ -73,6 +75,10 @@ function accumulateUsage(a: Usage, b: Usage): Usage {
73
75
  function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }
74
76
  function isTransient(error: unknown): boolean { const text = errorText(error).toLowerCase(); return ["429", "rate limit", "overloaded", "timeout", "timed out", "econnreset", "etimedout", "503", "502", "500", "unavailable", "temporarily"].some((part) => text.includes(part)); }
75
77
  function isOverloadFault(error: unknown): boolean { const text = errorText(error).toLowerCase(); return text.includes("529") || text.includes("overload"); }
78
+ function thinkingLadderForModel(model: Model<any> | undefined): readonly CodingAgentThinkingLevel[] {
79
+ const efforts = (model as CodingAgentModel | undefined)?.reasoningEfforts;
80
+ return efforts?.length ? efforts : DEFAULT_THINKING_LADDER;
81
+ }
76
82
 
77
83
  export function withGatewayBaseUrl<M extends { baseUrl: string; provider: string }>(model: M, gatewayBaseUrls: Record<string, string> | undefined): M {
78
84
  const url = gatewayBaseUrls?.[model.provider];
@@ -129,10 +135,12 @@ class SessionConductorImpl implements SessionConductor {
129
135
  #matcher?: ModelMatcher;
130
136
  #state: ConductorState;
131
137
  #busy = false;
138
+ #activeWork?: Promise<unknown>;
139
+ #shellAbort?: AbortController;
132
140
  #draining = false;
133
141
  #queue: { mode: "steer" | "followUp"; text: string }[] = [];
134
142
  #boundModel?: Model<any>;
135
- #thinking: ThinkingLevel;
143
+ #thinking: CodingAgentThinkingLevel;
136
144
  #prePlanMode?: PermissionMode;
137
145
  #sessionName?: string;
138
146
  #gatewayBaseUrls?: Record<string, string>;
@@ -159,12 +167,22 @@ class SessionConductorImpl implements SessionConductor {
159
167
  clearQueue() { this.#queue.length = 0; this.#hub.emit({ kind: "queue", count: 0 }); }
160
168
  dequeueLast() { const item = this.#queue.pop(); if (item) this.#hub.emit({ kind: "queue", count: this.#queue.length }); return item?.text; }
161
169
  steerNow() { const item = this.#queue.pop(); if (!item) return false; this.#queue.unshift({ mode: "steer", text: item.text }); this.agent.abort(); return true; }
162
- abort() { this.clearQueue(); this.#condenseAbort?.abort(); this.agent.abort(); const fault = conductorFault("aborted", "turn aborted by caller"); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); this.#hub.emit({ kind: "idle" }); }
170
+ abort() { this.clearQueue(); this.#condenseAbort?.abort(); this.#shellAbort?.abort(); this.agent.abort(); const fault = conductorFault("aborted", "turn aborted by caller"); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); this.#hub.emit({ kind: "idle" }); }
163
171
 
164
172
  async submit(input: string) {
165
173
  if (this.#busy) { this.enqueue(input); return this.#state; }
166
174
  this.#busy = true;
167
- try { await this.#runTurn(input); await this.#drainQueue(); return this.#state; } finally { this.#busy = false; }
175
+ const work = (async () => {
176
+ try { await this.#runTurn(input); await this.#drainQueue(); return this.#state; }
177
+ finally { this.#busy = false; }
178
+ })();
179
+ this.#activeWork = work;
180
+ return work;
181
+ }
182
+ async #stopWork() {
183
+ if (!this.#busy) return;
184
+ this.abort();
185
+ await this.#activeWork;
168
186
  }
169
187
  async #drainQueue() { if (this.#draining) return; this.#draining = true; try { while (this.#queue.length) { const index = this.#queue.findIndex((item) => item.mode === "steer"); const item = index >= 0 ? this.#queue.splice(index, 1)[0] : this.#queue.shift()!; this.#hub.emit({ kind: "queue", count: this.#queue.length }); await this.#runTurn(item.text); } } finally { this.#draining = false; } }
170
188
  async #runTurn(input: string) {
@@ -196,30 +214,68 @@ class SessionConductorImpl implements SessionConductor {
196
214
  }
197
215
 
198
216
  selectModel(id: string) { const card = this.#matcher?.resolveCard(id); if (!card) { const fault = conductorFault("model", `no model matches "${id}"`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.#applyModel(card); }
199
- cycleModel(id: string) { this.selectModel(id); }
217
+ cycleModel(id: string, direction: -1 | 1 = 1) {
218
+ if (id.trim()) { this.selectModel(id); return; }
219
+ const cards = this.availableModels();
220
+ if (!cards.length) return;
221
+ const at = cards.findIndex(card => card.id === this.#state.modelId);
222
+ const index = at < 0 ? 0 : (at + direction + cards.length) % cards.length;
223
+ this.selectModel(cards[index].id);
224
+ }
200
225
  #applyModel(card: CatalogCard) { const model = withGatewayBaseUrl(card.model, this.#gatewayBaseUrls); this.agent.setModel(model); this.#boundModel = model; this.#dispatch({ type: "model", modelId: card.id }); }
201
226
  updateGatewayBaseUrls(map: Record<string, string>) { this.#gatewayBaseUrls = map; this.selectModel(this.#state.modelId); }
202
227
  registerTools(tools: AgentTool[]) { this.agent.setTools([...this.#deckTools, ...tools]); }
203
228
 
204
- async condense() { if (this.#busy) return "busy" as const; this.#busy = true; this.#condenseAbort = new AbortController(); try { const result = await this.#runCondense(true, this.#condenseAbort.signal); await this.#drainQueue(); return result; } finally { this.#busy = false; this.#condenseAbort = undefined; } }
229
+ async condense(instructions?: string) {
230
+ if (this.#busy) return "busy" as const;
231
+ this.#busy = true; this.#condenseAbort = new AbortController();
232
+ const work = (async () => {
233
+ try { const result = await this.#runCondense(true, this.#condenseAbort!.signal, instructions); await this.#drainQueue(); return result; }
234
+ finally { this.#busy = false; this.#condenseAbort = undefined; }
235
+ })();
236
+ this.#activeWork = work;
237
+ return work;
238
+ }
205
239
  cancelCondense() { this.#condenseAbort?.abort(); }
206
- async #runCondense(force: boolean, signal?: AbortSignal) {
240
+ async #runCondense(force: boolean, signal?: AbortSignal, instructions?: string) {
207
241
  const before = [...this.agent.state.messages]; this.#dispatch({ type: "phase", phase: "condensing" });
208
- try { const after = await (this.deps.condense ?? noopCondense)(before, { force, model: this.#boundModel, contextTokens: this.#state.contextTokens, signal }); if (signal?.aborted) return "cancelled" as const; if (after.length >= before.length) { this.#dispatch({ type: "settled" }); return "nothing" as const; } this.agent.replaceMessages(after); await this.store.reset(); for (let i = 0; i < after.length; i++) await this.store.append(after[i], i === 0 ? "condense" : undefined); this.#dispatch({ type: "head", head: this.store.head }); this.#hub.emit({ kind: "compacted", manual: force }); this.#dispatch({ type: "settled" }); return "condensed" as const; } catch (error) { const fault = conductorFault("overflow", "auto-condense failed", error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return "failed" as const; }
242
+ try { const after = await (this.deps.condense ?? noopCondense)(before, { force, model: this.#boundModel, contextTokens: this.#state.contextTokens, signal, instructions }); if (signal?.aborted) { this.#dispatch({type:"settled"}); this.#hub.emit({kind:"idle"}); return "cancelled" as const; } if (after.length >= before.length) { this.#dispatch({ type: "settled" }); return "nothing" as const; } this.agent.replaceMessages(after); await this.store.reset(); for (let i = 0; i < after.length; i++) await this.store.append(after[i], i === 0 ? "condense" : undefined); this.#dispatch({ type: "head", head: this.store.head }); this.#hub.emit({ kind: "compacted", manual: force }); this.#dispatch({ type: "settled" }); return "condensed" as const; } catch (error) { const fault = conductorFault("overflow", "auto-condense failed", error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return "failed" as const; }
209
243
  }
210
244
 
211
245
  async fork(id: string) { await this.#move(id, `cannot fork from "${id}"`); }
212
246
  async navigateTree(id: string) { await this.#move(id, `cannot navigate to "${id}"`); }
213
- async #move(id: string, message: string) { try { await this.store.branchAt(id); this.agent.replaceMessages(this.store.messages()); this.#dispatch({ type: "head", head: this.store.head }); this.#dispatch({ type: "settled" }); this.#hub.emit({ kind: "idle" }); } catch (error) { const fault = conductorFault("persistence", message, error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); } }
247
+ async #move(id: string, message: string) { try { await this.#stopWork(); await this.store.branchAt(id); this.agent.replaceMessages(this.store.messages()); this.#dispatch({ type: "head", head: this.store.head }); this.#dispatch({ type: "settled" }); this.#hub.emit({ kind: "reset" }); this.#hub.emit({ kind: "idle" }); } catch (error) { const fault = conductorFault("persistence", message, error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); } }
214
248
  async restoreCode(id: string) { return this.options.checkpoint?.restore(id) ?? []; }
215
249
  async codeRestoreInfo(id: string) { return { canRestore: this.options.checkpoint?.hasSnapshot(id) ?? false }; }
216
- async executeBash(command: string, opts: { excludeFromContext?: boolean } = {}) { const { spawn } = await import("node:child_process"); const outcome = await new Promise<{ output: string; exitCode: number }>((resolve) => { const child = spawn(command, { cwd: this.#workspace, shell: true }); let output = ""; child.stdout?.on("data", (chunk) => output += String(chunk)); child.stderr?.on("data", (chunk) => output += String(chunk)); child.on("error", (error) => resolve({ output: output + errorText(error), exitCode: 1 })); child.on("close", (code) => resolve({ output, exitCode: code ?? 1 })); }); if (!opts.excludeFromContext) await this.store.append({ role: "user", content: `$ ${command}\n${outcome.output}`.trimEnd(), timestamp: Date.now() } as AgentMessage, "note"); return outcome; }
250
+ async executeBash(command: string, opts: { excludeFromContext?: boolean; signal?: AbortSignal } = {}) {
251
+ if (this.#busy) throw new Error("A turn is running — stop it before running a shell command.");
252
+ this.#busy = true; this.#shellAbort = new AbortController();
253
+ const signal = opts.signal ? AbortSignal.any([opts.signal, this.#shellAbort.signal]) : this.#shellAbort.signal;
254
+ const work = (async () => {
255
+ try {
256
+ this.#dispatch({type:"phase",phase:"tooling"});
257
+ const outcome = await runShell(command, this.#workspace, signal);
258
+ if (!opts.excludeFromContext) {
259
+ const message: AgentMessage = {role:"user",content:`$ ${command}\n${outcome.output}`.trimEnd(),timestamp:Date.now()};
260
+ const entry = await this.store.append(message, "note");
261
+ this.agent.replaceMessages([...this.agent.state.messages, message]);
262
+ this.#dispatch({type:"head",head:this.store.head});
263
+ this.#hub.emit({kind:"persisted",entryId:entry.id});
264
+ }
265
+ this.#dispatch({type:"settled"}); this.#hub.emit({kind:"idle"});
266
+ await this.#drainQueue();
267
+ return outcome;
268
+ } finally { this.#busy = false; this.#shellAbort = undefined; }
269
+ })();
270
+ this.#activeWork = work;
271
+ return work;
272
+ }
217
273
 
218
274
  stats() { const messages = this.agent.state.messages; let userMessages = 0, assistantMessages = 0, toolCalls = 0, toolResults = 0; for (const message of messages) { if (message.role === "user") userMessages++; else if (message.role === "assistant") { assistantMessages++; for (const part of message.content ?? []) if (typeof part === "object" && part && part.type === "toolCall") toolCalls++; } else if (message.role === "toolResult") toolResults++; } const usage = this.#state.usage; return { sessionId: this.#state.head.sessionId, userMessages, assistantMessages, toolCalls, toolResults, totalMessages: messages.length, tokens: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, total: usage.totalTokens }, cost: usage.cost.total }; }
219
275
  costReport() { const stats = this.stats(); return [`Total cost: $${stats.cost.toFixed(4)}`, `Total tokens: ${stats.tokens.total}`, ` Input: ${stats.tokens.input}`, ` Output: ${stats.tokens.output}`, ` Cache read: ${stats.tokens.cacheRead}`, ` Cache write: ${stats.tokens.cacheWrite}`].join("\n"); }
220
276
  thinkingLevel() { return this.#thinking; }
221
- setThinkingLevel(level: ThinkingLevel) { this.#thinking = level; this.agent.setThinkingLevel?.(level); }
222
- cycleThinkingLevel() { const next = THINKING_LADDER[(THINKING_LADDER.indexOf(this.#thinking) + 1) % THINKING_LADDER.length]; this.setThinkingLevel(next); return next; }
277
+ setThinkingLevel(level: CodingAgentThinkingLevel) { if (!isReasoningEffortSupported(this.#boundModel?.id, level)) return; this.#thinking = level; this.agent.setThinkingLevel?.(level as ThinkingLevel); }
278
+ cycleThinkingLevel() { const ladder = thinkingLadderForModel(this.#boundModel); const next = ladder[(ladder.indexOf(this.#thinking) + 1) % ladder.length]; this.setThinkingLevel(next); return next; }
223
279
  permissionMode() { return this.modeHolder.mode; }
224
280
  setPermissionMode(mode: PermissionMode) { this.modeHolder.mode = mode; }
225
281
  setApprovalResolver(resolver: ApprovalResolver | undefined) { this.approvalHolder.resolver = resolver; }
@@ -228,7 +284,11 @@ class SessionConductorImpl implements SessionConductor {
228
284
  cyclePermissionMode() { const current = this.modeHolder.mode === "bypassPermissions" ? "bypass" : this.modeHolder.mode; const next = PERMISSION_CYCLE[(PERMISSION_CYCLE.indexOf(current) + 1) % PERMISSION_CYCLE.length]; this.setPermissionMode(next); return next; }
229
285
  sessionName() { return this.#sessionName; }
230
286
  setSessionName(name: string) { this.#sessionName = name; }
231
- async resume(id: string) { if (!await this.store.load(id)) { const fault = conductorFault("persistence", `no session "${id}" to resume`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.agent.replaceMessages(this.store.messages()); this.#state = { ...this.#state, head: this.store.head, usage: this.store.usage ?? createZeroUsage(), phase: "idle" }; this.#hub.emit({ kind: "idle" }); }
232
- async newSession() { this.agent.abort(); this.agent.replaceMessages([]); this.store.startNewSession(newSessionId()); this.#queue.length = 0; this.#sessionName = undefined; this.#state = { phase: "idle", head: this.store.head, usage: createZeroUsage(), contextTokens: 0, modelId: this.#state.modelId }; this.#hub.emit({ kind: "idle" }); }
233
- #dispatch(action: StateAction) { this.#state = reduceState(this.#state, action); }
287
+ async resume(id: string) { await this.#stopWork(); if (!await this.store.load(id)) { const fault = conductorFault("persistence", `no session "${id}" to resume`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.agent.replaceMessages(this.store.messages()); this.#state = { ...this.#state, head: this.store.head, usage: this.store.usage ?? createZeroUsage(), phase: "idle" }; this.#hub.emit({kind:"reset"}); this.#hub.emit({ kind: "idle" }); }
288
+ async newSession() { await this.#stopWork(); this.agent.replaceMessages([]); this.store.startNewSession(newSessionId()); this.#queue.length = 0; this.#sessionName = undefined; this.#state = { phase: "idle", head: this.store.head, usage: createZeroUsage(), contextTokens: 0, modelId: this.#state.modelId }; this.#hub.emit({kind:"reset"}); this.#hub.emit({ kind: "idle" }); }
289
+ #dispatch(action: StateAction) {
290
+ const phase = this.#state.phase;
291
+ this.#state = reduceState(this.#state, action);
292
+ if (phase !== this.#state.phase) this.#hub.emit({kind:"phase",phase:this.#state.phase});
293
+ }
234
294
  }
@@ -40,6 +40,7 @@ import type { AgentMessage, AgentTool, CanUseToolFn, ThinkingLevel } from "indus
40
40
  import type { KnownProvider, Model, Usage } from "indusagi/ai";
41
41
  import type { PermissionMode } from "../settings.js";
42
42
  import type { ApprovalResolver } from "./permissions.js";
43
+ export type CodingAgentThinkingLevel = ThinkingLevel | "max";
43
44
  /** Re-exported framework vocabulary that conductor consumers routinely need. */
44
45
  export type { AgentMessage, AgentTool, CanUseToolFn, ThinkingLevel, Model, Usage, KnownProvider };
45
46
  /** Re-exported permission vocabulary, so conductor consumers get it in one import. */
@@ -118,7 +119,7 @@ export function conductorFault(kind: FaultKind, message: string, cause?: unknown
118
119
  * - `queue` — the pending-input queue changed; `count` is its new depth.
119
120
  * - `idle` — the conductor has no in-flight work and is ready for input.
120
121
  */
121
- export type SessionSignal = {
122
+ export type SessionSignal = { readonly kind: "reset" } | { readonly kind: "phase"; readonly phase: ConductorPhase } | {
122
123
  readonly kind: "prompt";
123
124
  readonly text: string;
124
125
  } | {
@@ -369,6 +370,7 @@ export interface SessionStats {
369
370
  }
370
371
  /** Options for {@link SessionConductor.executeBash}. */
371
372
  export interface ExecuteBashOptions {
373
+ readonly signal?: AbortSignal;
372
374
  /**
373
375
  * When `true`, the command's output is *not* recorded as a transcript note,
374
376
  * so it never re-enters the agent's context. Defaults to `false`.
@@ -434,7 +436,7 @@ export interface SessionConductorOptions {
434
436
  /** Tools made available to the agent for this session. */
435
437
  readonly tools?: AgentTool[];
436
438
  /** Initial reasoning effort for models that support it. */
437
- readonly thinking?: ThinkingLevel;
439
+ readonly thinking?: CodingAgentThinkingLevel;
438
440
  /**
439
441
  * Canonical id of a model to fall back to when the bound model is overloaded
440
442
  * (HTTP 529 / "overloaded") mid-turn (`--fallback-model`). When set, an
@@ -653,7 +655,7 @@ export interface SessionConductor {
653
655
  * While the condense runs, {@link submit} queues instead of racing it, and the
654
656
  * queue drains once the condense settles.
655
657
  */
656
- condense(): Promise<CondenseOutcome>;
658
+ condense(instructions?: string): Promise<CondenseOutcome>;
657
659
  /**
658
660
  * Cancel an in-flight manual {@link condense} (the `/compact` Esc affordance).
659
661
  * The summarizer's result is discarded and the transcript stays untouched; a
@@ -718,19 +720,19 @@ export interface SessionConductor {
718
720
  */
719
721
  costReport(): string;
720
722
  /** The reasoning effort currently applied to the session. */
721
- thinkingLevel(): ThinkingLevel;
723
+ thinkingLevel(): CodingAgentThinkingLevel;
722
724
  /**
723
725
  * Set the reasoning effort for subsequent turns. Applied to the agent when it
724
726
  * exposes a setter; otherwise stored and applied on the next model bind.
725
727
  *
726
728
  * @param level the reasoning effort to apply
727
729
  */
728
- setThinkingLevel(level: ThinkingLevel): void;
730
+ setThinkingLevel(level: CodingAgentThinkingLevel): void;
729
731
  /**
730
732
  * Advance the reasoning effort to the next level in the cycle, applying it, and
731
733
  * return the newly-selected level.
732
734
  */
733
- cycleThinkingLevel(): ThinkingLevel;
735
+ cycleThinkingLevel(): CodingAgentThinkingLevel;
734
736
  /**
735
737
  * The permission mode currently in effect. The live `canUseTool` gate reads
736
738
  * this on every tool call, so the value reflects the most recent
@@ -841,5 +843,5 @@ export interface SessionConductor {
841
843
  *
842
844
  * @param id canonical id of the model to switch to
843
845
  */
844
- cycleModel?(id: string): void;
846
+ cycleModel?(id: string, direction?: -1 | 1): void;
845
847
  }
@@ -16,7 +16,7 @@
16
16
  * land; consumers import the conductor surface from `src/conductor` rather than
17
17
  * reaching into individual modules.
18
18
  */
19
- export type { FaultKind, ConductorFault, SessionSignal, SignalKind, SignalOf, SignalHandler, TranscriptSchema, TranscriptRole, TranscriptEntry, SessionHead, ModelCardRef, MatchQuery, ConductorPhase, ConductorState, QueueMode, QueuedInput, SessionStats, ExecuteBashOptions, BashOutcome, SessionConductor, SessionConductorOptions, CondenseOutcome, AgentMessage, AgentTool, CanUseToolFn, ThinkingLevel, Model, Usage, KnownProvider, PermissionMode, } from "./contract.js";
19
+ export type { FaultKind, ConductorFault, SessionSignal, SignalKind, SignalOf, SignalHandler, TranscriptSchema, TranscriptRole, TranscriptEntry, SessionHead, ModelCardRef, MatchQuery, ConductorPhase, ConductorState, QueueMode, QueuedInput, SessionStats, ExecuteBashOptions, BashOutcome, SessionConductor, SessionConductorOptions, CondenseOutcome, AgentMessage, AgentTool, CanUseToolFn, ThinkingLevel, CodingAgentThinkingLevel, Model, Usage, KnownProvider, PermissionMode, } from "./contract.js";
20
20
  export { conductorFault, TRANSCRIPT_SCHEMA } from "./contract.js";
21
21
  export { createSessionConductor, reduceState, noopCondense, withGatewayBaseUrl, type AgentLike, type ConductorDeps, type CondenseFn, type RetryPolicy, } from "./conductor.js";
22
22
  export { SignalHub, translateAgentEvent, type SignalHubOptions } from "./signal-hub/index.js";
@@ -4,6 +4,7 @@ import type { Usage } from "indusagi/ai";
4
4
  import { TRANSCRIPT_SCHEMA } from "../contract.js";
5
5
  import type { SessionHead, TranscriptEntry, TranscriptRole } from "../contract.js";
6
6
  import { encodeEntry, encodeHead, parseSessionText, roleForMessage } from "./serialize.js";
7
+ import { appendPrivateFileSync, atomicWritePrivateFileSync, readPrivateFileSync } from "../../security/private-state.js";
7
8
 
8
9
  export interface TranscriptBackend {
9
10
  locate(sessionId: string): string;
@@ -54,18 +55,13 @@ export function fsBackend(dir: string): TranscriptBackend {
54
55
  return {
55
56
  locate: (sessionId) => `${dir}/${sessionId}.ndjson`,
56
57
  read: async (sessionId) => {
57
- const fs = await import("node:fs/promises");
58
- try { return await fs.readFile(await fileOf(sessionId), "utf8"); } catch { return null; }
58
+ return readPrivateFileSync(await fileOf(sessionId));
59
59
  },
60
60
  append: async (sessionId, line) => {
61
- const fs = await import("node:fs/promises");
62
- await fs.mkdir(dir, { recursive: true });
63
- await fs.appendFile(await fileOf(sessionId), `${line}\n`, "utf8");
61
+ appendPrivateFileSync(await fileOf(sessionId), `${line}\n`);
64
62
  },
65
63
  rewrite: async (sessionId, text) => {
66
- const fs = await import("node:fs/promises");
67
- await fs.mkdir(dir, { recursive: true });
68
- await fs.writeFile(await fileOf(sessionId), text.length === 0 ? "" : `${text}\n`, "utf8");
64
+ atomicWritePrivateFileSync(await fileOf(sessionId), text.length === 0 ? "" : `${text}\n`);
69
65
  },
70
66
  };
71
67
  }
@@ -7,6 +7,7 @@
7
7
  * ready to use) both need the exact same answer, so it lives here once.
8
8
  */
9
9
  import { useEffect, useState } from "indusagi/react-host";
10
+ import { getEnvApiKey } from "indusagi/ai";
10
11
  import { hasValidServerToken } from "../boot/server-token.js";
11
12
  import { serverTierProviders } from "../boot/runners/server-mode.js";
12
13
  import type { OverlayServices } from "./contract.js";
@@ -27,10 +28,14 @@ export function useAuthenticatedProviders(services: OverlayServices | undefined,
27
28
  void (async () => {
28
29
  const found = new Set<string>();
29
30
  try {
30
- for (const provider of services.listLoginProviders()) {
31
+ const providers = new Set([
32
+ ...services.listLoginProviders().map(provider => provider.id),
33
+ ...services.conductor.availableModels().map(model => model.provider)
34
+ ]);
35
+ for (const provider of providers) {
31
36
  try {
32
- if ((await services.vault.listAccounts(provider.id)).length > 0) {
33
- found.add(provider.id);
37
+ if (getEnvApiKey(provider) || (await services.vault.listAccounts(provider)).length > 0) {
38
+ found.add(provider);
34
39
  }
35
40
  } catch {
36
41
  // ignore
@@ -51,6 +56,6 @@ export function useAuthenticatedProviders(services: OverlayServices | undefined,
51
56
  return () => {
52
57
  live = false;
53
58
  };
54
- }, [refreshKey]);
59
+ }, [services, refreshKey]);
55
60
  return authed;
56
61
  }
@@ -5,7 +5,7 @@ import { useState } from "indusagi/react-host";
5
5
  import { Box, Text, useInput } from "indusagi/react-host/ink";
6
6
  import { wrapSelectionIndex, type InkThemeAdapter } from "indusagi/react-ink";
7
7
  import { jsx, jsxs } from "indusagi/react-host/jsx-runtime";
8
- import type { AgentRun } from "./BackgroundAgents.js";
8
+ import { useAgentClock, type AgentRun } from "./BackgroundAgents.js";
9
9
 
10
10
  function formatTokens(tokens: number): string {
11
11
  return tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}k` : String(tokens);
@@ -66,7 +66,7 @@ export interface AgentsViewProps {
66
66
  }
67
67
 
68
68
  export function AgentsView({ theme, runs, activity, tokens, now, onExit }: AgentsViewProps) {
69
- const clock = now ?? Date.now();
69
+ const clock = useAgentClock(runs.some(run => run.status === "running"), now);
70
70
  const [selectedIndex, setSelectedIndex] = useState(runs.length > 0 ? 1 : 0);
71
71
  const rowCount = runs.length + 1;
72
72
  const index = Math.min(selectedIndex, rowCount - 1);
@@ -2,6 +2,7 @@
2
2
  * BackgroundAgents — the live sub-agent panel pinned below the input.
3
3
  */
4
4
  import { Box, Text } from "indusagi/react-host/ink";
5
+ import { useEffect, useState } from "indusagi/react-host";
5
6
  import { splitAssistantMessage, type InkThemeAdapter } from "indusagi/react-ink";
6
7
  import { jsx, jsxs } from "indusagi/react-host/jsx-runtime";
7
8
  import type { AgentMessage } from "indusagi/agent";
@@ -100,11 +101,23 @@ export interface BackgroundAgentsProps {
100
101
  /**
101
102
  * Render the background-agents panel, or nothing when no run is in flight.
102
103
  */
104
+ export function useAgentClock(active: boolean, supplied?: number): number {
105
+ const [now, setNow] = useState(Date.now);
106
+ useEffect(() => {
107
+ if (!active || supplied !== undefined) return;
108
+ const timer = setInterval(() => setNow(Date.now()), 1000);
109
+ return () => clearInterval(timer);
110
+ }, [active, supplied]);
111
+ return supplied ?? now;
112
+ }
113
+
103
114
  export function BackgroundAgents({ theme, runs, tokens, now }: BackgroundAgentsProps) {
104
- const clock = now ?? Date.now();
115
+ const clock = useAgentClock(runs.some(run => run.status === "running"), now);
105
116
  const runningCount = runs.reduce((n, run) => (run.status === "running" ? n + 1 : n), 0);
106
117
  if (runningCount === 0) return null;
107
- const visible = runs.slice(-MAX_VISIBLE_RUNS);
118
+ const running = runs.filter(run => run.status === "running");
119
+ const recentDone = runs.filter(run => run.status !== "running").slice(-Math.max(0, MAX_VISIBLE_RUNS - running.length));
120
+ const visible = [...running, ...recentDone].slice(0, MAX_VISIBLE_RUNS);
108
121
  return jsxs(Box, {
109
122
  flexDirection: "column",
110
123
  marginTop: 1,