@runuai/host 0.8.38 → 0.8.40
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/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +4 -0
- package/lib/agents/codex.ts +138 -6
- package/lib/engines.ts +29 -1
- package/package.json +1 -1
- package/src/ui/server.ts +54 -0
- package/ui/app.js +110 -29
- package/ui/style.css +23 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- ADR-078: Codex restart-survival. Persist the app-server thread id so a
|
|
2
|
+
-- respawned Codex (allowAttach:false → kill+respawn on host restart) can
|
|
3
|
+
-- `thread/resume` the SAME durable thread from its on-disk rollout instead of
|
|
4
|
+
-- starting a fresh one and losing the conversation. Null for non-codex kinds.
|
|
5
|
+
ALTER TABLE `host_agent_sessions` ADD `codex_thread_id` text;
|
package/db/schema.ts
CHANGED
|
@@ -144,6 +144,10 @@ export const hostAgentSessions = sqliteTable(
|
|
|
144
144
|
.notNull()
|
|
145
145
|
.default(0),
|
|
146
146
|
status: text("status").notNull().default("running"), // running|closed
|
|
147
|
+
// ADR-078: Codex thread id (from thread/start). Persisted so a respawned
|
|
148
|
+
// app-server after a host restart resumes the SAME thread (thread/resume)
|
|
149
|
+
// instead of losing the conversation. Null for non-codex kinds / pre-resume.
|
|
150
|
+
codexThreadId: text("codex_thread_id"),
|
|
147
151
|
createdAt: integer("created_at", { mode: "number" }).notNull(),
|
|
148
152
|
updatedAt: integer("updated_at", { mode: "number" }).notNull(),
|
|
149
153
|
},
|
package/lib/agents/codex.ts
CHANGED
|
@@ -32,6 +32,9 @@ import { existsSync } from "node:fs";
|
|
|
32
32
|
import { homedir } from "node:os";
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
|
|
35
|
+
import { and, eq } from "drizzle-orm";
|
|
36
|
+
|
|
37
|
+
import { getDb, schema } from "../db";
|
|
35
38
|
import { newId } from "../ulid";
|
|
36
39
|
import { createAgentTransport, type LineTransport } from "./transport";
|
|
37
40
|
import { isRateLimitMessage } from "./rate-limit";
|
|
@@ -211,6 +214,64 @@ const APPROVAL_METHODS = new Set([
|
|
|
211
214
|
* can legitimately run for minutes. */
|
|
212
215
|
const RPC_TIMEOUT_MS = 30_000;
|
|
213
216
|
|
|
217
|
+
/**
|
|
218
|
+
* ADR-078 thread/resume lock retry. Right after a host restart the previous
|
|
219
|
+
* (durable, in-container) app-server may still hold the thread's rollout lock;
|
|
220
|
+
* `thread/resume` then fails with JSON-RPC -32600 until the stop we sent the old
|
|
221
|
+
* runner lands. Retry that specific error a bounded number of times.
|
|
222
|
+
*/
|
|
223
|
+
const RESUME_LOCK_ATTEMPTS = 10;
|
|
224
|
+
const RESUME_LOCK_BACKOFF_MS = 1_000;
|
|
225
|
+
|
|
226
|
+
const delay = (ms: number): Promise<void> =>
|
|
227
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Thread persistence (ADR-078) — resume the SAME thread across a host restart.
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/** The Codex thread id persisted for (task, agent), or null if none yet. */
|
|
234
|
+
function readCodexThreadId(taskId: string, agentId: string): string | null {
|
|
235
|
+
try {
|
|
236
|
+
const row = getDb()
|
|
237
|
+
.select({ id: schema.hostAgentSessions.codexThreadId })
|
|
238
|
+
.from(schema.hostAgentSessions)
|
|
239
|
+
.where(
|
|
240
|
+
and(
|
|
241
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
242
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
.get();
|
|
246
|
+
return row?.id ?? null;
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Persist the Codex thread id for (task, agent) so a later host process resumes
|
|
254
|
+
* it. Best-effort — a write failure just means the next restart starts a fresh
|
|
255
|
+
* thread (today's behavior). The transport's session-row upsert never clears
|
|
256
|
+
* this column, so it survives the kill+respawn of a host restart.
|
|
257
|
+
*/
|
|
258
|
+
function saveCodexThreadId(taskId: string, agentId: string, threadId: string): void {
|
|
259
|
+
try {
|
|
260
|
+
getDb()
|
|
261
|
+
.update(schema.hostAgentSessions)
|
|
262
|
+
.set({ codexThreadId: threadId, updatedAt: Date.now() })
|
|
263
|
+
.where(
|
|
264
|
+
and(
|
|
265
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
266
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
.run();
|
|
270
|
+
} catch {
|
|
271
|
+
/* best effort — see doc comment */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
214
275
|
// ---------------------------------------------------------------------------
|
|
215
276
|
// The session.
|
|
216
277
|
// ---------------------------------------------------------------------------
|
|
@@ -222,6 +283,9 @@ export class CodexSession implements AgentSession {
|
|
|
222
283
|
private readonly proc: LineTransport;
|
|
223
284
|
private readonly handlers = new Set<AgentEventHandler>();
|
|
224
285
|
private readonly systemPreamble: string;
|
|
286
|
+
private readonly taskId: string;
|
|
287
|
+
/** ADR-078: a thread id persisted by a PRIOR host process, to resume. */
|
|
288
|
+
private readonly resumeThreadId: string | null;
|
|
225
289
|
private closed = false;
|
|
226
290
|
private handshakeOk = false;
|
|
227
291
|
|
|
@@ -245,6 +309,10 @@ export class CodexSession implements AgentSession {
|
|
|
245
309
|
}) {
|
|
246
310
|
this.agentId = args.agent.id;
|
|
247
311
|
this.systemPreamble = args.systemPreamble;
|
|
312
|
+
this.taskId = args.taskId;
|
|
313
|
+
// ADR-078: read BEFORE createAgentTransport (whose session-row upsert
|
|
314
|
+
// preserves this column). Non-null → a host restart; resume the thread.
|
|
315
|
+
this.resumeThreadId = readCodexThreadId(args.taskId, args.agent.id);
|
|
248
316
|
|
|
249
317
|
// The agent's model / effort (when set) are selected via config overrides
|
|
250
318
|
// (`-c model=<model>`, `-c model_reasoning_effort=<effort>`) on the
|
|
@@ -328,7 +396,15 @@ export class CodexSession implements AgentSession {
|
|
|
328
396
|
if (waiter) {
|
|
329
397
|
this.pending.delete(id);
|
|
330
398
|
if (isObj(msg.error)) {
|
|
331
|
-
|
|
399
|
+
// Preserve the JSON-RPC code so thread/resume can retry -32600
|
|
400
|
+
// (the thread's single-writer lock still held by a dying app-server).
|
|
401
|
+
const code =
|
|
402
|
+
typeof msg.error.code === "number" ? msg.error.code : undefined;
|
|
403
|
+
waiter.reject(
|
|
404
|
+
Object.assign(new Error(str(msg.error.message, "rpc error")), {
|
|
405
|
+
rpcCode: code,
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
332
408
|
} else {
|
|
333
409
|
waiter.resolve(msg.result);
|
|
334
410
|
}
|
|
@@ -355,6 +431,7 @@ export class CodexSession implements AgentSession {
|
|
|
355
431
|
const thread = msg.params.thread;
|
|
356
432
|
if (isObj(thread) && typeof thread.id === "string") {
|
|
357
433
|
this.threadId = thread.id;
|
|
434
|
+
saveCodexThreadId(this.taskId, this.agentId, thread.id);
|
|
358
435
|
}
|
|
359
436
|
}
|
|
360
437
|
for (const ev of mapCodexNotification(method, msg.params)) {
|
|
@@ -450,9 +527,22 @@ export class CodexSession implements AgentSession {
|
|
|
450
527
|
clientInfo: { name: "uai", version: "0.2" },
|
|
451
528
|
});
|
|
452
529
|
this.notify("initialized", {});
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
530
|
+
// ADR-078: a thread id from a prior host process means this is a restart —
|
|
531
|
+
// resume the SAME durable thread so the conversation survives, instead of
|
|
532
|
+
// starting fresh. Fall back to a new thread if resume isn't possible
|
|
533
|
+
// (thread gone/corrupt, or the old writer never released the lock).
|
|
534
|
+
if (this.resumeThreadId && (await this.tryResumeThread(this.resumeThreadId))) {
|
|
535
|
+
this.handshakeOk = true;
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
await this.startThread();
|
|
539
|
+
this.handshakeOk = true;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async startThread(): Promise<void> {
|
|
543
|
+
// The uai channel briefing (how to @-mention, the roster, the project's
|
|
544
|
+
// defaultPrompt) goes in as developer instructions so it applies to every
|
|
545
|
+
// turn on the thread (persisted with the thread, so resume needn't resend).
|
|
456
546
|
const threadParams: Record<string, unknown> = {
|
|
457
547
|
approvalPolicy: "never",
|
|
458
548
|
sandbox: "danger-full-access",
|
|
@@ -462,9 +552,51 @@ export class CodexSession implements AgentSession {
|
|
|
462
552
|
}
|
|
463
553
|
const result = await this.request("thread/start", threadParams);
|
|
464
554
|
if (isObj(result) && isObj(result.thread)) {
|
|
465
|
-
|
|
555
|
+
const id = str(result.thread.id) || this.threadId;
|
|
556
|
+
this.threadId = id;
|
|
557
|
+
if (id) saveCodexThreadId(this.taskId, this.agentId, id);
|
|
466
558
|
}
|
|
467
|
-
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Reopen the persisted thread by id (ADR-078). `excludeTurns:true` returns
|
|
563
|
+
* only metadata — no history replay, which would double-post the transcript
|
|
564
|
+
* the cloud already holds. The app-server allows one writer per thread, so a
|
|
565
|
+
* -32600 (lock still held by the dying predecessor app-server) is retried with
|
|
566
|
+
* backoff; any other failure returns false and the caller starts a fresh
|
|
567
|
+
* thread. Returns true iff the thread is now open for turns.
|
|
568
|
+
*/
|
|
569
|
+
private async tryResumeThread(threadId: string): Promise<boolean> {
|
|
570
|
+
for (let attempt = 0; attempt < RESUME_LOCK_ATTEMPTS; attempt++) {
|
|
571
|
+
try {
|
|
572
|
+
const result = await this.request("thread/resume", {
|
|
573
|
+
threadId,
|
|
574
|
+
excludeTurns: true,
|
|
575
|
+
approvalPolicy: "never",
|
|
576
|
+
sandbox: "danger-full-access",
|
|
577
|
+
});
|
|
578
|
+
const id =
|
|
579
|
+
isObj(result) && isObj(result.thread) && str(result.thread.id)
|
|
580
|
+
? str(result.thread.id)
|
|
581
|
+
: threadId;
|
|
582
|
+
this.threadId = id;
|
|
583
|
+
saveCodexThreadId(this.taskId, this.agentId, id);
|
|
584
|
+
return true;
|
|
585
|
+
} catch (err) {
|
|
586
|
+
const code = (err as { rpcCode?: number }).rpcCode;
|
|
587
|
+
if (code === -32600 && attempt < RESUME_LOCK_ATTEMPTS - 1) {
|
|
588
|
+
await delay(RESUME_LOCK_BACKOFF_MS);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
console.warn(
|
|
592
|
+
`[codex] thread/resume ${threadId} failed (${
|
|
593
|
+
err instanceof Error ? err.message : String(err)
|
|
594
|
+
}) — starting a fresh thread`,
|
|
595
|
+
);
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return false;
|
|
468
600
|
}
|
|
469
601
|
|
|
470
602
|
// -- AgentSession ---------------------------------------------------------
|
package/lib/engines.ts
CHANGED
|
@@ -414,6 +414,13 @@ export interface ConnectOptions {
|
|
|
414
414
|
apiKey?: string;
|
|
415
415
|
/** token-command manual fallback: a pasted token or API key (Claude). */
|
|
416
416
|
pastedToken?: string;
|
|
417
|
+
/**
|
|
418
|
+
* ADR-076: when set, a token captured by the token-command flow (or pasted)
|
|
419
|
+
* is handed to this sink INSTEAD of being persisted as the default credential
|
|
420
|
+
* — used to register an EXTRA account. The sink's result becomes the connect
|
|
421
|
+
* result.
|
|
422
|
+
*/
|
|
423
|
+
onToken?: (token: string) => ConnectResult | Promise<ConnectResult>;
|
|
417
424
|
}
|
|
418
425
|
|
|
419
426
|
export interface ConnectResult {
|
|
@@ -465,6 +472,9 @@ export async function connectEngine(
|
|
|
465
472
|
message: "That doesn't look like a token. Paste just the value.",
|
|
466
473
|
};
|
|
467
474
|
}
|
|
475
|
+
// Adding an extra account? Route the pasted value to the sink instead of
|
|
476
|
+
// the default credential.
|
|
477
|
+
if (opts.onToken) return await opts.onToken(token);
|
|
468
478
|
// One paste box serves both credential kinds — classify by prefix so an
|
|
469
479
|
// `sk-ant-api…` platform key doesn't get stored as an OAuth token.
|
|
470
480
|
upsertEnvLocal(
|
|
@@ -476,7 +486,7 @@ export async function connectEngine(
|
|
|
476
486
|
);
|
|
477
487
|
return { ok: true, message: `${d.label} connected.` };
|
|
478
488
|
}
|
|
479
|
-
return runTokenCommand(kind, d.label, onLog, s);
|
|
489
|
+
return runTokenCommand(kind, d.label, onLog, s, opts.onToken);
|
|
480
490
|
}
|
|
481
491
|
|
|
482
492
|
return runLoginCommand(kind, d.label, onLog, s);
|
|
@@ -1028,6 +1038,7 @@ async function runTokenCommand(
|
|
|
1028
1038
|
label: string,
|
|
1029
1039
|
onLog: (line: string) => void,
|
|
1030
1040
|
s: EngineSeams,
|
|
1041
|
+
onToken?: (token: string) => ConnectResult | Promise<ConnectResult>,
|
|
1031
1042
|
): Promise<ConnectResult> {
|
|
1032
1043
|
let child: ChildProcess;
|
|
1033
1044
|
try {
|
|
@@ -1052,9 +1063,26 @@ async function runTokenCommand(
|
|
|
1052
1063
|
resolve(r);
|
|
1053
1064
|
};
|
|
1054
1065
|
let raw = "";
|
|
1066
|
+
let capturing = false;
|
|
1055
1067
|
const forwarded = new Set<string>();
|
|
1056
1068
|
const acked = new Set<string>();
|
|
1057
1069
|
const saveToken = (token: string): void => {
|
|
1070
|
+
// The token renders on several redraws (and again on exit) — capture once.
|
|
1071
|
+
if (capturing) return;
|
|
1072
|
+
capturing = true;
|
|
1073
|
+
// ADR-076: adding an EXTRA account routes the captured token to the sink
|
|
1074
|
+
// (which stores it as a separate account) instead of the default env.
|
|
1075
|
+
if (onToken) {
|
|
1076
|
+
Promise.resolve(onToken(token))
|
|
1077
|
+
.then((r) => done(r, true))
|
|
1078
|
+
.catch((err) =>
|
|
1079
|
+
done(
|
|
1080
|
+
{ ok: false, message: err instanceof Error ? err.message : String(err) },
|
|
1081
|
+
true,
|
|
1082
|
+
),
|
|
1083
|
+
);
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1058
1086
|
upsertEnvLocal("CLAUDE_CODE_OAUTH_TOKEN", token, s);
|
|
1059
1087
|
done({ ok: true, message: `${label} connected.` }, true);
|
|
1060
1088
|
};
|
package/package.json
CHANGED
package/src/ui/server.ts
CHANGED
|
@@ -162,6 +162,8 @@ async function handle(
|
|
|
162
162
|
return await handleEngineInstall(req, res);
|
|
163
163
|
case "/api/engines/disconnect":
|
|
164
164
|
return await handleEngineDisconnect(req, res, opts);
|
|
165
|
+
case "/api/engines/accounts/connect":
|
|
166
|
+
return await handleEngineAccountConnect(req, res, opts);
|
|
165
167
|
case "/api/engines/accounts/add":
|
|
166
168
|
return await handleEngineAccountAdd(req, res, opts);
|
|
167
169
|
case "/api/engines/accounts/remove":
|
|
@@ -357,6 +359,58 @@ async function handleEngineAccountAdd(
|
|
|
357
359
|
});
|
|
358
360
|
}
|
|
359
361
|
|
|
362
|
+
/**
|
|
363
|
+
* POST /api/engines/accounts/connect `{kind, label}` → run the engine's
|
|
364
|
+
* token-command sign-in (streamed NDJSON exactly like /api/engines/connect) and
|
|
365
|
+
* store the captured token as an EXTRA account (ADR-076) rather than the default
|
|
366
|
+
* credential. Only meaningful for token-command kinds (Claude); the browser
|
|
367
|
+
* OAuth authorizes whichever account you sign in with, so run it signed-out /
|
|
368
|
+
* incognito for the browser to prompt for the second account.
|
|
369
|
+
*/
|
|
370
|
+
async function handleEngineAccountConnect(
|
|
371
|
+
req: IncomingMessage,
|
|
372
|
+
res: ServerResponse,
|
|
373
|
+
opts: UiServerOptions,
|
|
374
|
+
): Promise<void> {
|
|
375
|
+
const body = await readJsonBody(req);
|
|
376
|
+
const kind = body?.kind;
|
|
377
|
+
if (!isEngineKind(kind)) {
|
|
378
|
+
return sendError(res, 400, "unknown or missing engine kind");
|
|
379
|
+
}
|
|
380
|
+
const label = typeof body?.label === "string" ? body.label.trim() : "";
|
|
381
|
+
if (!label) {
|
|
382
|
+
return sendError(res, 400, "give the account a label");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
res.writeHead(200, {
|
|
386
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
387
|
+
"cache-control": "no-store",
|
|
388
|
+
});
|
|
389
|
+
const emit = (obj: unknown): void => {
|
|
390
|
+
res.write(`${JSON.stringify(obj)}\n`);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
let result: { ok: boolean; message: string };
|
|
394
|
+
try {
|
|
395
|
+
result = await connectEngine(
|
|
396
|
+
kind,
|
|
397
|
+
{ onToken: (token) => addEngineAccount(kind, label, { token }) },
|
|
398
|
+
(line) => emit({ line }),
|
|
399
|
+
);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
result = {
|
|
402
|
+
ok: false,
|
|
403
|
+
message: err instanceof Error ? err.message : "sign-in failed",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (result.ok) {
|
|
407
|
+
opts.readvertise?.();
|
|
408
|
+
void ensureStandardImage();
|
|
409
|
+
}
|
|
410
|
+
emit({ done: true, ok: result.ok, message: result.message });
|
|
411
|
+
res.end();
|
|
412
|
+
}
|
|
413
|
+
|
|
360
414
|
/** POST /api/engines/accounts/remove `{id}` → forget an EXTRA account. */
|
|
361
415
|
async function handleEngineAccountRemove(
|
|
362
416
|
req: IncomingMessage,
|
package/ui/app.js
CHANGED
|
@@ -390,15 +390,15 @@ function openAddAccount(e) {
|
|
|
390
390
|
}
|
|
391
391
|
|
|
392
392
|
function addAccountForm(e) {
|
|
393
|
+
const isToken = e.authMode === "token-command"; // Claude: browser sign-in
|
|
393
394
|
const form = document.createElement("div");
|
|
394
395
|
form.className = "engine-setup";
|
|
395
396
|
|
|
396
397
|
const note = document.createElement("p");
|
|
397
398
|
note.className = "setup-note";
|
|
398
|
-
note.textContent =
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
: "Paste an OpenAI API key (starts with sk-…). This account rotates in automatically when another account of this kind hits its limit.";
|
|
399
|
+
note.textContent = isToken
|
|
400
|
+
? "Sign in with the account you want to add — a browser opens to authorize Claude and the token is captured here automatically. This account rotates in automatically when another Claude account hits its limit."
|
|
401
|
+
: "Paste an OpenAI API key (starts with sk-…). This account rotates in automatically when another account of this kind hits its limit.";
|
|
402
402
|
form.append(note);
|
|
403
403
|
|
|
404
404
|
const labelField = document.createElement("div");
|
|
@@ -412,33 +412,89 @@ function addAccountForm(e) {
|
|
|
412
412
|
labelField.append(labelInput);
|
|
413
413
|
form.append(labelField);
|
|
414
414
|
|
|
415
|
+
const status = document.createElement("div");
|
|
416
|
+
status.className = "setup-status";
|
|
417
|
+
const log = document.createElement("pre");
|
|
418
|
+
log.className = "log";
|
|
419
|
+
log.hidden = true;
|
|
420
|
+
const pushLine = (line) => {
|
|
421
|
+
log.textContent += (log.textContent ? "\n" : "") + line;
|
|
422
|
+
log.scrollTop = log.scrollHeight;
|
|
423
|
+
};
|
|
424
|
+
const fail = (msg) => {
|
|
425
|
+
status.className = "setup-status err";
|
|
426
|
+
status.textContent = msg;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const actions = document.createElement("div");
|
|
430
|
+
actions.className = "setup-actions";
|
|
431
|
+
|
|
432
|
+
// --- Automatic sign-in (token-command / Claude) --------------------------
|
|
433
|
+
if (isToken) {
|
|
434
|
+
const signIn = document.createElement("button");
|
|
435
|
+
signIn.className = "btn";
|
|
436
|
+
signIn.type = "button";
|
|
437
|
+
signIn.textContent = "Sign in with Claude";
|
|
438
|
+
signIn.addEventListener("click", async () => {
|
|
439
|
+
const label = labelInput.value.trim();
|
|
440
|
+
if (!label) return fail("Give the account a label first.");
|
|
441
|
+
signIn.disabled = true;
|
|
442
|
+
signIn.textContent = "Signing in…";
|
|
443
|
+
status.className = "setup-status";
|
|
444
|
+
status.textContent = "A browser will open to authorize Claude…";
|
|
445
|
+
log.hidden = false;
|
|
446
|
+
log.textContent = "";
|
|
447
|
+
const result = await runStream(
|
|
448
|
+
"/api/engines/accounts/connect",
|
|
449
|
+
{ kind: e.kind, label },
|
|
450
|
+
pushLine,
|
|
451
|
+
);
|
|
452
|
+
if (result.ok) {
|
|
453
|
+
await poll();
|
|
454
|
+
closeModal();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
signIn.disabled = false;
|
|
458
|
+
signIn.textContent = "Try again";
|
|
459
|
+
fail(result.message || "Sign-in failed.");
|
|
460
|
+
});
|
|
461
|
+
actions.append(signIn);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const cancel = document.createElement("button");
|
|
465
|
+
cancel.className = "link-btn";
|
|
466
|
+
cancel.type = "button";
|
|
467
|
+
cancel.textContent = "Cancel";
|
|
468
|
+
cancel.addEventListener("click", closeModal);
|
|
469
|
+
actions.append(cancel);
|
|
470
|
+
form.append(actions, status, log);
|
|
471
|
+
|
|
472
|
+
// --- Manual fallback: copyable command + paste box -----------------------
|
|
473
|
+
if (isToken) {
|
|
474
|
+
const or = document.createElement("p");
|
|
475
|
+
or.className = "setup-note setup-or";
|
|
476
|
+
or.textContent =
|
|
477
|
+
"Can't use the browser here? Run this in a terminal, then paste the token:";
|
|
478
|
+
form.append(or, copyRow("claude setup-token"));
|
|
479
|
+
}
|
|
480
|
+
|
|
415
481
|
const secretField = document.createElement("div");
|
|
416
482
|
secretField.className = "field";
|
|
417
483
|
const secretInput = document.createElement("textarea");
|
|
418
484
|
secretInput.className = "text-input token-area";
|
|
419
485
|
secretInput.rows = 3;
|
|
420
|
-
secretInput.placeholder =
|
|
421
|
-
e.kind === "claude" ? "Claude token or API key" : "OpenAI API key";
|
|
486
|
+
secretInput.placeholder = isToken ? "Claude token or API key" : "OpenAI API key";
|
|
422
487
|
secretInput.autocomplete = "off";
|
|
423
488
|
secretInput.spellcheck = false;
|
|
424
489
|
secretField.append(secretInput);
|
|
425
490
|
form.append(secretField);
|
|
426
491
|
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
form.append(status);
|
|
430
|
-
|
|
431
|
-
const fail = (msg) => {
|
|
432
|
-
status.className = "setup-status err";
|
|
433
|
-
status.textContent = msg;
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
const actions = document.createElement("div");
|
|
437
|
-
actions.className = "setup-actions";
|
|
492
|
+
const pasteActions = document.createElement("div");
|
|
493
|
+
pasteActions.className = "setup-actions";
|
|
438
494
|
const save = document.createElement("button");
|
|
439
|
-
save.className = "btn";
|
|
495
|
+
save.className = isToken ? "link-btn" : "btn";
|
|
440
496
|
save.type = "button";
|
|
441
|
-
save.textContent = "Add account";
|
|
497
|
+
save.textContent = isToken ? "Add pasted token" : "Add account";
|
|
442
498
|
save.addEventListener("click", async () => {
|
|
443
499
|
const label = labelInput.value.trim();
|
|
444
500
|
const secret = secretInput.value.trim();
|
|
@@ -467,23 +523,48 @@ function addAccountForm(e) {
|
|
|
467
523
|
return;
|
|
468
524
|
}
|
|
469
525
|
save.disabled = false;
|
|
470
|
-
save.textContent = "Add account";
|
|
526
|
+
save.textContent = isToken ? "Add pasted token" : "Add account";
|
|
471
527
|
fail((result && result.message) || "Couldn't add the account.");
|
|
472
528
|
});
|
|
473
|
-
|
|
529
|
+
pasteActions.append(save);
|
|
530
|
+
form.append(pasteActions);
|
|
474
531
|
|
|
475
|
-
const cancel = document.createElement("button");
|
|
476
|
-
cancel.className = "link-btn";
|
|
477
|
-
cancel.type = "button";
|
|
478
|
-
cancel.textContent = "Cancel";
|
|
479
|
-
cancel.addEventListener("click", closeModal);
|
|
480
|
-
actions.append(cancel);
|
|
481
|
-
|
|
482
|
-
form.append(actions);
|
|
483
532
|
setTimeout(() => labelInput.focus(), 0);
|
|
484
533
|
return form;
|
|
485
534
|
}
|
|
486
535
|
|
|
536
|
+
/** A monospace command line with a Copy button (writes to the clipboard). */
|
|
537
|
+
function copyRow(cmd) {
|
|
538
|
+
const row = document.createElement("div");
|
|
539
|
+
row.className = "copy-row";
|
|
540
|
+
const code = document.createElement("code");
|
|
541
|
+
code.className = "copy-cmd";
|
|
542
|
+
code.textContent = cmd;
|
|
543
|
+
const btn = document.createElement("button");
|
|
544
|
+
btn.className = "link-btn";
|
|
545
|
+
btn.type = "button";
|
|
546
|
+
btn.textContent = "Copy";
|
|
547
|
+
btn.addEventListener("click", async () => {
|
|
548
|
+
try {
|
|
549
|
+
await navigator.clipboard.writeText(cmd);
|
|
550
|
+
btn.textContent = "Copied";
|
|
551
|
+
setTimeout(() => {
|
|
552
|
+
btn.textContent = "Copy";
|
|
553
|
+
}, 1500);
|
|
554
|
+
} catch {
|
|
555
|
+
// Clipboard API blocked (non-secure context) — select the text so the
|
|
556
|
+
// user can copy it manually.
|
|
557
|
+
const range = document.createRange();
|
|
558
|
+
range.selectNodeContents(code);
|
|
559
|
+
const sel = window.getSelection();
|
|
560
|
+
sel.removeAllRanges();
|
|
561
|
+
sel.addRange(range);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
row.append(code, btn);
|
|
565
|
+
return row;
|
|
566
|
+
}
|
|
567
|
+
|
|
487
568
|
// --- add-engine modal -------------------------------------------------------
|
|
488
569
|
|
|
489
570
|
function openModal() {
|
package/ui/style.css
CHANGED
|
@@ -614,6 +614,29 @@ textarea.token-area {
|
|
|
614
614
|
white-space: pre-wrap;
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
+
/* Copyable command row (e.g. `claude setup-token`) + Copy button. */
|
|
618
|
+
.copy-row {
|
|
619
|
+
display: flex;
|
|
620
|
+
align-items: center;
|
|
621
|
+
gap: 0.6rem;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.copy-cmd {
|
|
625
|
+
flex: 1;
|
|
626
|
+
padding: 0.45rem 0.7rem;
|
|
627
|
+
border: 1px solid var(--line);
|
|
628
|
+
border-radius: 8px;
|
|
629
|
+
background: var(--bg);
|
|
630
|
+
color: var(--fg);
|
|
631
|
+
font-family: var(--mono);
|
|
632
|
+
font-size: 0.85rem;
|
|
633
|
+
word-break: break-all;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.setup-or {
|
|
637
|
+
margin-top: 0.4rem;
|
|
638
|
+
}
|
|
639
|
+
|
|
617
640
|
.setup-actions {
|
|
618
641
|
display: flex;
|
|
619
642
|
align-items: center;
|