talos-code 0.1.0 → 0.2.0
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/CHANGELOG.md +87 -0
- package/README.md +9 -4
- package/dist/args.js +4 -1
- package/dist/commands/provider-cli.js +146 -87
- package/dist/config/types.js +1 -1
- package/dist/i18n/en/approval.js +3 -0
- package/dist/i18n/en/credentials.js +10 -0
- package/dist/i18n/en/errors.js +21 -0
- package/dist/i18n/en/firstrun.js +12 -0
- package/dist/i18n/en/screen.js +38 -0
- package/dist/i18n/error-view.js +1 -1
- package/dist/main.js +2 -1
- package/dist/provider/openrouter-login.js +172 -0
- package/dist/runtime/context-archive.js +69 -0
- package/dist/runtime/supervisor.js +5 -2
- package/dist/runtime/talos-composition.js +7 -2
- package/dist/tui/agent-view.js +51 -0
- package/dist/tui/app.js +364 -27
- package/dist/tui/catalog-service.js +2 -1
- package/dist/tui/components/status-indicator.js +10 -4
- package/dist/tui/components/terminal-shell.js +10 -1
- package/dist/tui/descendant-approvals.js +104 -0
- package/dist/tui/launch-state.js +9 -0
- package/dist/tui/live-activity.js +13 -2
- package/dist/tui/overlays/agent-tree.js +7 -3
- package/dist/tui/overlays/approval-dialog.js +3 -1
- package/dist/tui/overlays/model-picker.js +5 -0
- package/dist/tui/overlays/provider-picker.js +26 -0
- package/dist/tui/project-trust-prompt.js +5 -3
- package/dist/tui/session-controller.js +25 -12
- package/dist/tui/setup-wizard.js +46 -0
- package/dist/tui/slash-commands.js +3 -1
- package/dist/tui/theme-catalog.js +3 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/vendor/manifest.json +1 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/*
|
|
10
|
+
* P21 (owner, 2026-09-26: "la possibilità di fare accesso oauth con Open Router, come fa l'applicazione desktop"; decisions
|
|
11
|
+
* the same day): sign in to OpenRouter from the CLI without pasting a key. The end of the flow is a plain OpenRouter API key,
|
|
12
|
+
* stored like a pasted one.
|
|
13
|
+
*
|
|
14
|
+
* The PKCE arithmetic is the desktop's own (`harness-ui/src/openrouter-oauth.mjs`, researched 2026-09-10 on OpenRouter's
|
|
15
|
+
* OAuth PKCE docs and RFC 7636): S256 always, the verifier never leaves this process, a single-use code that OpenRouter lets
|
|
16
|
+
* live 10 minutes, `state` carried in the callback PATH because OpenRouter documents no `state`, and error codes without
|
|
17
|
+
* response bodies. This file adds what a CLI needs, read on 2026-09-26:
|
|
18
|
+
* - a one-shot loopback server on 127.0.0.1 and a port the system picks, open only while the sign-in lasts (RFC 8252 §7.3,
|
|
19
|
+
* §8.3: the IP literal, not `localhost`; listen on loopback only; close the port once the answer is in); the system browser,
|
|
20
|
+
* never an embedded one (§8.12). Pi (`packages/ai/src/auth/oauth/openrouter.ts`) and Hermes (`hermes_cli/auth_openrouter.py`)
|
|
21
|
+
* do the same, Codex for its own sign-in (`codex-rs/login/src/server.rs`);
|
|
22
|
+
* - a code pasted by hand, raced against the browser (Pi): the code alone or the whole address the browser landed on;
|
|
23
|
+
* - a remote session (SSH) goes to OpenRouter's no-callback mode at once, where the page SHOWS the code (Hermes).
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
import { createServer } from 'node:http';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { pathToFileURL } from 'node:url';
|
|
29
|
+
/** The desktop's PKCE module, from the checkout or from the package's own copy of the kernel (`vendor/harness-ui`). */
|
|
30
|
+
export async function loadOpenRouterOAuth(repoRoot) {
|
|
31
|
+
return await import(__rewriteRelativeImportExtension(pathToFileURL(join(repoRoot, 'harness-ui', 'src', 'openrouter-oauth.mjs')).href));
|
|
32
|
+
}
|
|
33
|
+
/** What OpenRouter writes beside the key on the person's key page, so they can tell which program asked and revoke it. */
|
|
34
|
+
export const LOGIN_LABEL = 'TALOS CLI';
|
|
35
|
+
/** OpenRouter's code lives 10 minutes ("The code is single-use and expires after 10 minutes"); the wait is the same. */
|
|
36
|
+
export const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
37
|
+
const EXCHANGE_TIMEOUT_MS = 30_000;
|
|
38
|
+
export function isRemoteSession(env = process.env) {
|
|
39
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
40
|
+
}
|
|
41
|
+
/** The code in what the person pasted: the code itself, or the address (or query) the browser landed on. */
|
|
42
|
+
export function codeFromInput(input) {
|
|
43
|
+
const value = String(input ?? '').trim();
|
|
44
|
+
if (!value)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(value);
|
|
48
|
+
return url.searchParams.get('code') || null;
|
|
49
|
+
}
|
|
50
|
+
catch { /* not an address */ }
|
|
51
|
+
if (value.includes('code=')) {
|
|
52
|
+
const code = new URLSearchParams(value.replace(/^[^?]*\?/u, '')).get('code');
|
|
53
|
+
return code || null;
|
|
54
|
+
}
|
|
55
|
+
return /\s/u.test(value) ? null : value;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Opens the address in the system browser, without a shell (on Windows `&` in an address would split a `cmd` line).
|
|
59
|
+
* A test profile never opens a window: the address is only shown.
|
|
60
|
+
*/
|
|
61
|
+
export function openInBrowser(url, { env = process.env, platform = process.platform, spawnImpl = spawn } = {}) {
|
|
62
|
+
if (env.TALOS_TEST_PROFILE === '1')
|
|
63
|
+
return false;
|
|
64
|
+
if (!/^https:\/\//u.test(url))
|
|
65
|
+
return false;
|
|
66
|
+
const [command, args] = platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
|
|
67
|
+
try {
|
|
68
|
+
const child = spawnImpl(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
69
|
+
child.on('error', () => { });
|
|
70
|
+
child.unref();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export class OpenRouterLoginError extends Error {
|
|
78
|
+
code;
|
|
79
|
+
status;
|
|
80
|
+
constructor(code, message, status = null) { super(message); this.name = 'OpenRouterLoginError'; this.code = code; this.status = status; }
|
|
81
|
+
}
|
|
82
|
+
const failure = (error) => error instanceof OpenRouterLoginError ? error : new OpenRouterLoginError(String(error?.code ?? 'OAUTH_FAILED'), 'The sign-in did not complete.', typeof error?.stato === 'number' ? error.stato : null);
|
|
83
|
+
const PAGE = (title) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>TALOS</title></head><body style="font-family:sans-serif;margin:3em"><h1>${title}</h1></body></html>`;
|
|
84
|
+
export async function startOpenRouterLogin({ oauth, env = process.env, paste = false, open = openInBrowser, fetchImpl = globalThis.fetch, timeoutMs = LOGIN_TIMEOUT_MS, label = LOGIN_LABEL }) {
|
|
85
|
+
const { verifier, challenge } = oauth.creaCoppiaPkce();
|
|
86
|
+
const state = oauth.creaStato();
|
|
87
|
+
const byHand = paste || isRemoteSession(env);
|
|
88
|
+
let settled = false, claimed = false, server = null, timer = null;
|
|
89
|
+
let resolveKey = () => { };
|
|
90
|
+
let rejectKey = () => { };
|
|
91
|
+
const key = new Promise((resolve, reject) => { resolveKey = resolve; rejectKey = reject; });
|
|
92
|
+
key.catch(() => { });
|
|
93
|
+
/* The port stops taking requests as soon as the sign-in is decided (RFC 8252 §8.3); the page the browser is waiting for is
|
|
94
|
+
still sent (a forced close there showed the person a broken connection instead of "Signed in"). Only a cancel or a
|
|
95
|
+
timeout drops the open connections too. */
|
|
96
|
+
const close = (force) => { if (timer) {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
timer = null;
|
|
99
|
+
} if (server) {
|
|
100
|
+
server.close();
|
|
101
|
+
if (force)
|
|
102
|
+
server.closeAllConnections?.();
|
|
103
|
+
server = null;
|
|
104
|
+
} };
|
|
105
|
+
const finish = (outcome, force = false) => { if (settled)
|
|
106
|
+
return; settled = true; close(force); if ('key' in outcome)
|
|
107
|
+
resolveKey(outcome.key);
|
|
108
|
+
else
|
|
109
|
+
rejectKey(outcome.error); };
|
|
110
|
+
const fetchDiRete = ((input, init) => fetchImpl(input, { ...init, signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS) }));
|
|
111
|
+
const exchange = async (code) => {
|
|
112
|
+
claimed = true;
|
|
113
|
+
try {
|
|
114
|
+
const { chiave } = await oauth.scambiaCodicePerChiave({ codice: code, verifier, fetchDiRete });
|
|
115
|
+
finish({ key: chiave });
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
finish({ error: failure(error) });
|
|
119
|
+
}
|
|
120
|
+
return key;
|
|
121
|
+
};
|
|
122
|
+
let callbackUrl = null;
|
|
123
|
+
if (!byHand) {
|
|
124
|
+
const path = `/openrouter/callback/${encodeURIComponent(state)}`;
|
|
125
|
+
server = createServer((request, response) => {
|
|
126
|
+
const send = (status, title) => { response.statusCode = status; response.setHeader('content-type', 'text/html; charset=utf-8'); response.setHeader('cache-control', 'no-store'); response.setHeader('connection', 'close'); response.end(PAGE(title)); };
|
|
127
|
+
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
128
|
+
if (request.method !== 'GET' || requestUrl.pathname !== path) {
|
|
129
|
+
send(404, 'Not found.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (claimed || settled) {
|
|
133
|
+
send(409, 'This sign-in has already been used.');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (requestUrl.searchParams.get('error')) {
|
|
137
|
+
send(400, 'OpenRouter sign-in was cancelled. You can close this page.');
|
|
138
|
+
finish({ error: new OpenRouterLoginError('OAUTH_DENIED', 'OpenRouter sign-in was cancelled in the browser.') });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const code = requestUrl.searchParams.get('code');
|
|
142
|
+
if (!code) {
|
|
143
|
+
send(400, 'OpenRouter returned no code.');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
void exchange(code).then(() => send(200, 'Signed in to OpenRouter. You can close this page and go back to TALOS.'), () => send(502, 'The sign-in did not complete. Go back to TALOS.'));
|
|
147
|
+
});
|
|
148
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); });
|
|
149
|
+
const address = server.address();
|
|
150
|
+
if (!address || typeof address === 'string') {
|
|
151
|
+
close(true);
|
|
152
|
+
throw new OpenRouterLoginError('OAUTH_CALLBACK_UNAVAILABLE', 'TALOS could not listen for OpenRouter on this machine.');
|
|
153
|
+
}
|
|
154
|
+
callbackUrl = `http://127.0.0.1:${address.port}${path}`;
|
|
155
|
+
}
|
|
156
|
+
const url = oauth.indirizzoDiAutorizzazione({ challenge, callbackUrl, etichetta: label });
|
|
157
|
+
timer = setTimeout(() => finish({ error: new OpenRouterLoginError('OAUTH_TIMEOUT', 'The sign-in took longer than 10 minutes.') }, true), timeoutMs);
|
|
158
|
+
timer.unref?.();
|
|
159
|
+
const opened = byHand ? false : open(url);
|
|
160
|
+
return {
|
|
161
|
+
url, mode: byHand ? 'paste' : 'browser', opened, key,
|
|
162
|
+
async submit(input) {
|
|
163
|
+
if (settled || claimed)
|
|
164
|
+
return key;
|
|
165
|
+
const code = codeFromInput(input);
|
|
166
|
+
if (!code)
|
|
167
|
+
throw new OpenRouterLoginError('OAUTH_CODE_MISSING', 'That is not a code from OpenRouter.');
|
|
168
|
+
return exchange(code);
|
|
169
|
+
},
|
|
170
|
+
cancel() { finish({ error: new OpenRouterLoginError('OAUTH_CANCELLED', 'The sign-in was cancelled.') }, true); },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* P12 (owner, 2026-09-25, "quando riprendo sessione mi dà CTX_HISTORY_DIVERGED"). Measured on a copy of the owner's
|
|
3
|
+
* session and Context Engine archive: the archive held 10 messages, the last an assistant message with a tool call and NO
|
|
4
|
+
* result (12:16:45, the turn interrupted there); the session's own history had dropped that unanswered call, as it must (a
|
|
5
|
+
* call without its result is not sent back to a model), and gone on with the next person's messages. From then on the
|
|
6
|
+
* archive was no longer a prefix of the history, `syncOriginals` (harness-ui/src/context-desktop-service.mjs:202) refused
|
|
7
|
+
* every turn, and the session could not be resumed.
|
|
8
|
+
* Where the unanswered call came from: the kernel archives the whole history at several points of a turn, among them
|
|
9
|
+
* right after the model's answer, BEFORE the tools run (talosHarness.mjs `capture` reason 'response'). The cure, on the
|
|
10
|
+
* CLI's side of the bridge (the kernel and the service are not edited): `capture` never archives an assistant message
|
|
11
|
+
* whose tool calls do not all have a result yet, nor the partial results after it. The next capture, once the results
|
|
12
|
+
* are in, archives the exchange whole; if the turn stops first, nothing unanswered was archived. The archive stays a
|
|
13
|
+
* prefix of the history in both cases.
|
|
14
|
+
*/
|
|
15
|
+
/** The longest prefix of `messages` that does not end inside an unanswered tool exchange. */
|
|
16
|
+
export function archivableHistory(messages) {
|
|
17
|
+
const rows = messages;
|
|
18
|
+
for (let index = rows.length - 1; index >= 0; index--) {
|
|
19
|
+
const message = rows[index];
|
|
20
|
+
if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls) || message.tool_calls.length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
const wanted = new Set(message.tool_calls.map((call) => call?.id).filter((id) => typeof id === 'string' && id));
|
|
23
|
+
for (let later = index + 1; later < rows.length && rows[later]?.role === 'tool'; later++)
|
|
24
|
+
wanted.delete(rows[later].tool_call_id);
|
|
25
|
+
/* The last tool exchange decides: complete, the whole history is archivable; unanswered, it and what follows wait. */
|
|
26
|
+
return wanted.size === 0 ? [...messages] : messages.slice(0, index);
|
|
27
|
+
}
|
|
28
|
+
return [...messages];
|
|
29
|
+
}
|
|
30
|
+
/*
|
|
31
|
+
* The same measurement found a race: `syncOriginals` reads the session revision, then appends with it
|
|
32
|
+
* (context-desktop-service.mjs:199-204), while a Context Engine compaction running in the background can commit a version
|
|
33
|
+
* in between (outside that session's queue). The append is then refused with CTX_STALE_REVISION and the TURN failed
|
|
34
|
+
* (r5a-coord-ce-compaction: 3 of 8 runs once capture waited for whole tool exchanges, 0 of 11 before; the race existed
|
|
35
|
+
* before too, only rarer). A retry is safe: `syncOriginals` rereads the revision and appends only what is missing, and
|
|
36
|
+
* `prepare` recomputes from the archive. So `capture` and `prepare` retry a stale revision, briefly.
|
|
37
|
+
* The shape follows Hermes' SQLite write retry (hermes_state.py:467-489, read 2026-09-26): patience is TIME-based, since
|
|
38
|
+
* "attempt-counted budgets destroyed turns on a healthy store", and each wait is RANDOM (20-150 ms), so two writers that
|
|
39
|
+
* lost the same race do not collide again in step (the "full jitter" of AWS's "Exponential Backoff and Jitter"). The
|
|
40
|
+
* budget is short, like Hermes' wait on a live compression lock: a compaction's commit is one write, and the revision reread
|
|
41
|
+
* after it succeeds; a revision that keeps moving for two seconds is a real conflict, and the error goes on.
|
|
42
|
+
*/
|
|
43
|
+
const STALE_PATIENCE_MS = 2_000, STALE_WAIT_MIN_MS = 20, STALE_WAIT_MAX_MS = 150;
|
|
44
|
+
async function retryStale(operation, patienceMs) {
|
|
45
|
+
const deadline = Date.now() + patienceMs;
|
|
46
|
+
for (;;) {
|
|
47
|
+
try {
|
|
48
|
+
return await operation();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const remaining = deadline - Date.now();
|
|
52
|
+
if (error?.code !== 'CTX_STALE_REVISION' || remaining <= 0)
|
|
53
|
+
throw error;
|
|
54
|
+
const wait = STALE_WAIT_MIN_MS + Math.random() * (STALE_WAIT_MAX_MS - STALE_WAIT_MIN_MS);
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(wait, remaining)));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** The service's kernel hooks: `capture` archives only whole tool exchanges; `capture` and `prepare` retry a stale revision. */
|
|
60
|
+
export function withArchivableCapture(hooks, { stalePatienceMs = STALE_PATIENCE_MS } = {}) {
|
|
61
|
+
if (!hooks || typeof hooks.capture !== 'function')
|
|
62
|
+
return hooks;
|
|
63
|
+
const capture = hooks.capture, prepare = hooks.prepare;
|
|
64
|
+
return {
|
|
65
|
+
...hooks,
|
|
66
|
+
capture: (input) => retryStale(() => capture({ ...input, messages: Array.isArray(input?.messages) ? archivableHistory(input.messages) : input?.messages }), stalePatienceMs),
|
|
67
|
+
...(typeof prepare === 'function' ? { prepare: (input) => retryStale(() => prepare(input), stalePatienceMs) } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -158,9 +158,12 @@ export function createRuntimeSupervisor(options) {
|
|
|
158
158
|
return;
|
|
159
159
|
const sequence = nativeSequence(event);
|
|
160
160
|
if (sequence !== null) {
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
/* P13 (2026-09-26): what this subscriber has seen is ITS cursor. The buffer is shared by every subscriber of the
|
|
162
|
+
session, so "already in the buffer" dropped the whole history of a second subscriber (a reopened agent view came
|
|
163
|
+
up empty); it still records the event, and a different event at a known sequence still throws a conflict. */
|
|
164
|
+
if (sequence <= cursor)
|
|
163
165
|
return;
|
|
166
|
+
replay.push(sessionId, event);
|
|
164
167
|
cursor = sequence;
|
|
165
168
|
}
|
|
166
169
|
sink(event);
|
|
@@ -12,6 +12,7 @@ import { secretValuesFromEnvironment } from "../diagnostics/redact.js";
|
|
|
12
12
|
import { isTestProfile } from "../provider/store.js";
|
|
13
13
|
import { compactionEvent, createCliEventHub, createRunObserver, RETRY_AFTER_MAX_MS, summaryRequestedEvent } from "./provider-attempts.js";
|
|
14
14
|
import { createToolOutputStore, pruneToolOutput } from "./output-store.js";
|
|
15
|
+
import { withArchivableCapture } from "./context-archive.js";
|
|
15
16
|
import { CONTEXT_ENGINE_MIN_WINDOW, configureModelProfiles, createModelsDevLoader, modelProfile, modelProfileEvidence, ollamaWindow, rememberOllamaWindow, setModelOverrides } from "./model-profile.js";
|
|
16
17
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
18
|
import { randomUUID } from 'node:crypto';
|
|
@@ -34,7 +35,7 @@ import { attachKeyOrigin, createEnvironmentKeyConsent, describeKeyOrigin, provid
|
|
|
34
35
|
import { createProviderControlPlane } from "../provider/control-plane.js";
|
|
35
36
|
function remapService(s) { return s.replace(/^talos-harness-/u, 'talos-cli-'); }
|
|
36
37
|
function contextProfileValue(value) { return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : null; }
|
|
37
|
-
function createCliContextBridge({ repoRoot, paths, registry, providerRegistry, providerStore, ownerRuntime, chatImageStore, separaFonteModello, prepareProfile, onSummaryRequest }) {
|
|
38
|
+
export function createCliContextBridge({ repoRoot, paths, registry, providerRegistry, providerStore, ownerRuntime, chatImageStore, separaFonteModello, prepareProfile, onSummaryRequest }) {
|
|
38
39
|
/*
|
|
39
40
|
* ⭐ R5a lane R, S2 (owner decision Q-R5-3): the profile — window and reserve — comes from `modelProfile` (model-profile.ts),
|
|
40
41
|
* which layers the person's override, a local Ollama's own window, the provider's rows, the registry's documented rows and
|
|
@@ -215,8 +216,10 @@ function createCliContextBridge({ repoRoot, paths, registry, providerRegistry, p
|
|
|
215
216
|
return service.request({ sessionId, method: 'GET', path: '/' });
|
|
216
217
|
};
|
|
217
218
|
return {
|
|
219
|
+
/* P12: `capture` archives only whole tool exchanges (context-archive.ts), so an interrupted turn cannot leave an
|
|
220
|
+
unanswered call in the archive that the history then drops (CTX_HISTORY_DIVERGED on every later turn). */
|
|
218
221
|
async hooks(input) { await prepareForSession(input.sessionId); const profile = profileForSession(input.sessionId); if (!profile)
|
|
219
|
-
return undefined; const { service } = await load(); await ensureSettings(service, input.sessionId); return service.createKernelHooks(input); },
|
|
222
|
+
return undefined; const { service } = await load(); await ensureSettings(service, input.sessionId); return withArchivableCapture(await service.createKernelHooks(input)); },
|
|
220
223
|
async compact(input) { await prepareForSession(input.sessionId); const profile = profileForSession(input.sessionId); if (!profile)
|
|
221
224
|
return undefined; const { service } = await load(); await ensureSettings(service, input.sessionId); return service.compact(input); },
|
|
222
225
|
async status(sessionId) {
|
|
@@ -962,6 +965,8 @@ export async function composeTalosRuntime({ repoRoot, projectRoot, paths, model,
|
|
|
962
965
|
createProbe: storeProbe, environmentKey: (id) => environmentKeys === 'use' ? null : consent.stateFor(id),
|
|
963
966
|
});
|
|
964
967
|
tuiCatalog = createTuiCatalogService({
|
|
968
|
+
/* P21: OpenRouter sign-in with the kernel's PKCE module (the checkout's, or the package's vendor copy). */
|
|
969
|
+
openRouterLogin: async (input) => { const { loadOpenRouterOAuth, startOpenRouterLogin } = await import("../provider/openrouter-login.js"); return startOpenRouterLogin({ oauth: await loadOpenRouterOAuth(repoRoot), ...(input?.paste ? { paste: true } : {}) }); },
|
|
965
970
|
publicProviders: () => { try {
|
|
966
971
|
return providerStore.listPublic?.() ?? [];
|
|
967
972
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createTuiEventAdapter } from "./event-adapter.js";
|
|
2
|
+
import { createTranscriptModel, reduceTranscriptEvent } from "./transcript-model.js";
|
|
3
|
+
const TERMINAL = new Set(['run.completed', 'run.failed', 'run.cancelled']);
|
|
4
|
+
/** Folds a child's events into its view: the transcript, and whether its run is running or over. */
|
|
5
|
+
export function reduceAgentView(state, event) {
|
|
6
|
+
const transcript = reduceTranscriptEvent(state.transcript, event);
|
|
7
|
+
if (event.type === 'run.started')
|
|
8
|
+
return { ...state, transcript, running: true, done: false };
|
|
9
|
+
if (TERMINAL.has(event.type))
|
|
10
|
+
return { ...state, transcript, running: false, done: true };
|
|
11
|
+
return transcript === state.transcript ? state : { ...state, transcript };
|
|
12
|
+
}
|
|
13
|
+
export function createAgentView({ runtime, target, secretValues = [], onChange, batchMs = 16 }) {
|
|
14
|
+
const adapter = createTuiEventAdapter(secretValues);
|
|
15
|
+
adapter.bindSession(target.sessionId);
|
|
16
|
+
let state = { ...target, transcript: createTranscriptModel(), running: false, done: false };
|
|
17
|
+
let replaying = true, closed = false, timer = null;
|
|
18
|
+
/* A streamed answer is many small events: the screen is told at most once per frame. */
|
|
19
|
+
const notify = () => {
|
|
20
|
+
if (closed || replaying || timer)
|
|
21
|
+
return;
|
|
22
|
+
timer = setTimeout(() => { timer = null; if (!closed)
|
|
23
|
+
onChange(state); }, batchMs);
|
|
24
|
+
};
|
|
25
|
+
let off = () => { };
|
|
26
|
+
try {
|
|
27
|
+
off = runtime.subscribe(target.sessionId, raw => {
|
|
28
|
+
if (closed)
|
|
29
|
+
return;
|
|
30
|
+
for (const event of adapter.translateAll(raw))
|
|
31
|
+
state = reduceAgentView(state, replaying && event.type === 'run.started' ? { ...event, history: true } : event);
|
|
32
|
+
notify();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
replaying = false;
|
|
37
|
+
}
|
|
38
|
+
if (!closed)
|
|
39
|
+
onChange(state);
|
|
40
|
+
return {
|
|
41
|
+
state: () => state,
|
|
42
|
+
close() { if (closed)
|
|
43
|
+
return; closed = true; if (timer) {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
timer = null;
|
|
46
|
+
} try {
|
|
47
|
+
off();
|
|
48
|
+
}
|
|
49
|
+
catch { /* nothing left to undo */ } },
|
|
50
|
+
};
|
|
51
|
+
}
|