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.
- package/.env.review.example +25 -0
- package/README.md +30 -0
- package/REVIEW_AGENT.md +137 -0
- package/dist/entry.js +1861 -979
- package/dist/guardrails.js +212 -36
- package/dist/index.js +2829 -1057
- package/dist/review-agent.js +4461 -0
- package/dist/review-server.js +4633 -0
- package/package.json +24 -7
- package/src/boot/auth-vault.ts +4 -5
- package/src/boot/contract.ts +1 -1
- package/src/boot/runners/addon-wiring.ts +111 -3
- package/src/boot/runners/benchmark-integrity.ts +107 -0
- package/src/boot/runners/repl-runner.ts +2 -1
- package/src/boot/runners/session.ts +26 -12
- package/src/boot/server-token.ts +20 -11
- package/src/boot/stages.ts +3 -6
- package/src/briefing/context-docs.ts +144 -12
- package/src/channels/oneshot.ts +6 -0
- package/src/conductor/catalog/catalog.ts +3 -1
- package/src/conductor/conductor.ts +77 -17
- package/src/conductor/contract.ts +9 -7
- package/src/conductor/index.ts +1 -1
- package/src/conductor/transcript-store/store.ts +4 -8
- package/src/console/auth-status.ts +9 -4
- package/src/console/components/AgentsView.ts +2 -2
- package/src/console/components/BackgroundAgents.ts +15 -2
- package/src/console/components/Banner.ts +20 -9
- package/src/console/components/Composer.ts +57 -82
- package/src/console/components/ScrollViewport.ts +46 -0
- package/src/console/components/TerminalConsole.ts +165 -162
- package/src/console/components/WorkingIndicator.ts +6 -5
- package/src/console/components/banner-sweep.ts +5 -1
- package/src/console/components/welcome.ts +3 -3
- package/src/console/contract.ts +10 -5
- package/src/console/index.ts +7 -7
- package/src/console/input/complete.ts +2 -2
- package/src/console/input/dir-reader.ts +3 -1
- package/src/console/input/graphemes.ts +22 -0
- package/src/console/input/keymap.ts +6 -4
- package/src/console/mount.ts +45 -3
- package/src/console/overlays/auth.ts +28 -8
- package/src/console/overlays/boards.ts +13 -40
- package/src/console/overlays/host.ts +10 -0
- package/src/console/overlays/pickers.ts +35 -30
- package/src/console/reducer.ts +12 -5
- package/src/console/slash/builtins.ts +4 -3
- package/src/console/slash/commands/integrations.ts +13 -14
- package/src/console/slash/commands/transcript.ts +3 -8
- package/src/console/slash/commands/workbench.ts +4 -4
- package/src/console/test-support.ts +48 -0
- package/src/index.ts +1 -0
- package/src/integrations/sarvam/config.ts +15 -13
- package/src/kit/external-editor.ts +17 -1
- package/src/kit/run-shell.ts +31 -0
- package/src/launch/catalog.ts +4 -2
- package/src/launch/contract.ts +1 -0
- package/src/launch/invocation/flags.ts +13 -1
- package/src/launch/invocation/read.ts +4 -3
- package/src/launch/login.ts +23 -10
- package/src/launch/oauth.ts +3 -0
- package/src/models.ts +166 -0
- package/src/review-agent/config.ts +75 -0
- package/src/review-agent/contract.ts +59 -0
- package/src/review-agent/github.ts +242 -0
- package/src/review-agent/index.ts +8 -0
- package/src/review-agent/prompt.ts +132 -0
- package/src/review-agent/reviewer.ts +118 -0
- package/src/review-agent/server.ts +221 -0
- package/src/review-agent/service.ts +165 -0
- package/src/review-agent/supabase.ts +209 -0
- package/src/review-agent/webhook.ts +76 -0
- package/src/security/private-state.ts +122 -0
- package/src/window-budget/summarize/condense.ts +4 -1
- package/src/workspace/brand.ts +1 -1
- package/src/workspace/locator.ts +2 -2
- package/supabase/migrations/20260827031803_indusagi_review_agent.sql +109 -0
- package/supabase/migrations/20260827035747_atomic_webhook_enqueue.sql +75 -0
- package/supabase/migrations/20260827040104_check_run_reconciliation.sql +8 -0
- package/supabase/migrations/20260827041049_owned_job_leases.sql +80 -0
- package/supabase/migrations/20260827042500_static_token_webhooks.sql +75 -0
|
@@ -1,19 +1,151 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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"
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export function
|
|
19
|
-
|
|
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
|
+
}
|
package/src/channels/oneshot.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
222
|
-
cycleThinkingLevel() { const 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
|
|
233
|
-
#dispatch(action: StateAction) {
|
|
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?:
|
|
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():
|
|
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:
|
|
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():
|
|
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
|
}
|
package/src/conductor/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
found.add(provider
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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,
|