@oddessentials/agent-guild 1.18.0 → 1.20.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/README.md +11 -4
- package/README.zh-CN.md +67 -0
- package/README.zh-TW.md +67 -0
- package/bin/agent-guild.mjs +16 -0
- package/docs/api.md +33 -13
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/package.json +1 -1
- package/src/manager/antigravity-transcript.mjs +4 -70
- package/src/manager/card-names.mjs +110 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/history-transcripts.mjs +598 -0
- package/src/manager/install-channels.mjs +8 -2
- package/src/manager/launch-flags.mjs +2 -3
- package/src/manager/launchers.mjs +66 -0
- package/src/manager/main.mjs +4 -2
- package/src/manager/providers.mjs +8 -10
- package/src/manager/pty.mjs +26 -0
- package/src/manager/server.mjs +4 -0
- package/src/manager/session-history.mjs +135 -66
- package/src/manager/session-hooks.mjs +2 -2
- package/src/manager/session-manager.mjs +53 -9
- package/src/manager/session.mjs +21 -18
- package/src/mcp/server.mjs +11 -0
- package/web/app.js +38 -45
- package/web/history-preview.js +7 -7
- package/web/index.html +1 -1
- package/web/yard/art.json +1 -1
- package/web/yard/model.mjs +1 -1
- package/web/yard/renderer.js +1 -1
|
@@ -64,9 +64,16 @@ export class SessionManager extends EventEmitter {
|
|
|
64
64
|
* @param {string|null} [opts.shimDir] folder with the agent-guild-report launchers, put first on PATH
|
|
65
65
|
* @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
|
|
66
66
|
* @param {import('./github.mjs').GitHub|null} [opts.github]
|
|
67
|
+
* @param {ReturnType<import('./card-names.mjs').createCardNames>|null} [opts.cardNames] names given to cards, by tool session id
|
|
67
68
|
*/
|
|
68
|
-
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null, store = null }) {
|
|
69
|
+
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null, store = null, cardNames = null }) {
|
|
69
70
|
super();
|
|
71
|
+
this.cardNames = cardNames;
|
|
72
|
+
/** Each card's latest choice of name for the tool session it holds, in the order the choices were made. */
|
|
73
|
+
this.nameChoices = new WeakMap();
|
|
74
|
+
this.nameOrder = 0;
|
|
75
|
+
/** Tool session → the order of the newest choice written to the store for it. */
|
|
76
|
+
this.namesWritten = new Map();
|
|
70
77
|
this.registry = registry;
|
|
71
78
|
this.baseEnv = baseEnv;
|
|
72
79
|
this.getApiUrl = getApiUrl;
|
|
@@ -144,11 +151,12 @@ export class SessionManager extends EventEmitter {
|
|
|
144
151
|
if (!current) throw httpError(404, `unknown provider "${provider.id}"`, 'unknown_provider');
|
|
145
152
|
const signIn = this.registry.account(current, account);
|
|
146
153
|
this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
|
|
147
|
-
const
|
|
148
|
-
|| (
|
|
149
|
-
||
|
|
154
|
+
const defaultName = (current.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null)
|
|
155
|
+
|| (runShell && this.registry.shellsFor(provider).shells.length > 1 ? `${provider.tool} · ${runShell.label}` : null)
|
|
156
|
+
|| provider.tool;
|
|
157
|
+
const given = cleanName(name) || (resumeId ? this.cardNames?.get(provider.id, signIn.id, resumeId) : null) || null;
|
|
150
158
|
const extraEnv = hooks.env ? { ...runShell?.env, ...hooks.env } : runShell?.env;
|
|
151
|
-
const options = { provider, cwd: workDir, cols, rows, name:
|
|
159
|
+
const options = { provider, cwd: workDir, cols, rows, name: given ?? defaultName, defaultName, named: given !== null, resume: resumeId, account: signIn, reporting: hooks.reporting, extraEnv };
|
|
152
160
|
if (runShell?.multiplexer) return this._withMultiplexerStart(provider.id, runShell.id, () => this._startMultiplexer(options, runShell, args || []));
|
|
153
161
|
const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args, runShell);
|
|
154
162
|
const session = this._spawn({ ...options, spawnSpec });
|
|
@@ -209,7 +217,7 @@ export class SessionManager extends EventEmitter {
|
|
|
209
217
|
}
|
|
210
218
|
this._track(session, { spawnSpec, mux, shell, card: {
|
|
211
219
|
id, reportToken, provider: provider.id, account: account.id, shell: shell.id,
|
|
212
|
-
muxName, name: session.name, cwd: session.cwd, createdAt: session.createdAt,
|
|
220
|
+
muxName, name: session.name, defaultName: session.defaultName, named: session.named, cwd: session.cwd, createdAt: session.createdAt,
|
|
213
221
|
// Herdr's request arguments belong to its client and must survive rebinding.
|
|
214
222
|
// Tmux's request arguments belong to the already-running inner session.
|
|
215
223
|
...(shell.id === 'herdr' ? { args: [...args] } : {}),
|
|
@@ -247,13 +255,44 @@ export class SessionManager extends EventEmitter {
|
|
|
247
255
|
this._saveCards();
|
|
248
256
|
session.on('exit', () => this._checkMultiplexer(session, tracked));
|
|
249
257
|
session.on('changed', () => {
|
|
250
|
-
if (card.name === session.name && (card.context?.compactions ?? 0) === session.context.compactions && (card.context?.resets ?? 0) === session.context.resets) return;
|
|
258
|
+
if (card.name === session.name && card.named === session.named && (card.context?.compactions ?? 0) === session.context.compactions && (card.context?.resets ?? 0) === session.context.resets) return;
|
|
251
259
|
card.name = session.name;
|
|
260
|
+
card.named = session.named;
|
|
252
261
|
card.context = { ...session.context };
|
|
253
262
|
this._saveCards();
|
|
254
263
|
});
|
|
255
264
|
}
|
|
256
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The store holds what a card last chose for the tool session it holds: its name, or none for its default. A
|
|
268
|
+
* card's choice is written once, when it is made: a rename, a new tool session, or the card's start. One whose
|
|
269
|
+
* write failed is written again on a later change, unless a newer choice for that tool session was written since,
|
|
270
|
+
* so a card shown again never puts back a name another card replaced. A card nobody named writes nothing.
|
|
271
|
+
*/
|
|
272
|
+
_keepCardName(session) {
|
|
273
|
+
// The page's identity for the tool session a card holds; a resumed card knows it before the tool reports.
|
|
274
|
+
const toolSessionId = session.toolSessionId ?? session.resume;
|
|
275
|
+
if (!this.cardNames || session.task !== null || !toolSessionId || !session.nameChosen) return;
|
|
276
|
+
const account = session.account?.id ?? 'default';
|
|
277
|
+
const key = `${session.provider.id}\0${account}\0${toolSessionId}`;
|
|
278
|
+
const name = session.named ? session.name : null;
|
|
279
|
+
let choice = this.nameChoices.get(session);
|
|
280
|
+
if (choice?.key !== key || choice.name !== name) {
|
|
281
|
+
choice = { key, name, order: ++this.nameOrder, written: false };
|
|
282
|
+
this.nameChoices.set(session, choice);
|
|
283
|
+
}
|
|
284
|
+
if (choice.written) return;
|
|
285
|
+
if ((this.namesWritten.get(key) ?? 0) > choice.order) {
|
|
286
|
+
choice.written = true;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// set() and delete() say whether the store now holds the choice; get() cannot tell an unreadable store from one without it.
|
|
290
|
+
const saved = name ? this.cardNames.set(session.provider.id, account, toolSessionId, name) : this.cardNames.delete(session.provider.id, account, toolSessionId);
|
|
291
|
+
if (!saved) return;
|
|
292
|
+
choice.written = true;
|
|
293
|
+
this.namesWritten.set(key, choice.order);
|
|
294
|
+
}
|
|
295
|
+
|
|
257
296
|
_saveCards() {
|
|
258
297
|
// A stopping manager leaves the file as it is, for the next one to bring the cards back.
|
|
259
298
|
if (!this.store || this.restoring || this.closing) return;
|
|
@@ -309,12 +348,13 @@ export class SessionManager extends EventEmitter {
|
|
|
309
348
|
const spawnSpec = this.registry.spawnSpec(provider, shell.id === 'herdr' ? card.args ?? [] : [], null, [], mux.named);
|
|
310
349
|
const session = this._spawn({
|
|
311
350
|
provider, spawnSpec: null, cwd: typeof card.cwd === 'string' ? card.cwd : os.homedir(), name: card.name, account,
|
|
351
|
+
defaultName: typeof card.defaultName === 'string' ? card.defaultName : undefined, named: card.named === true,
|
|
312
352
|
id: card.id, reportToken: card.reportToken, dropEnv: mux.dropEnv, extraEnv: shell.env,
|
|
313
353
|
createdAt: typeof card.createdAt === 'string' && !Number.isNaN(Date.parse(card.createdAt)) ? card.createdAt : undefined,
|
|
314
354
|
context: card.context,
|
|
315
355
|
multiplexer: { label: shell.label, attach: shell.multiplexer.attach.replaceAll('{name}', card.muxName), reattachable: true },
|
|
316
356
|
});
|
|
317
|
-
this._track(session, { spawnSpec, mux, shell, card: { ...card, name: session.name, cwd: session.cwd, createdAt: session.createdAt } });
|
|
357
|
+
this._track(session, { spawnSpec, mux, shell, card: { ...card, name: session.name, defaultName: session.defaultName, named: session.named, cwd: session.cwd, createdAt: session.createdAt } });
|
|
318
358
|
}
|
|
319
359
|
|
|
320
360
|
/** Attach a stopped tmux or herdr session's card to its multiplexer session again, keeping its id and report token. */
|
|
@@ -711,7 +751,7 @@ export class SessionManager extends EventEmitter {
|
|
|
711
751
|
}
|
|
712
752
|
|
|
713
753
|
_spawn({
|
|
714
|
-
provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
|
|
754
|
+
provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, defaultName, named = false, resume = null, task = null, installKind = null, installPath = null, account = null,
|
|
715
755
|
extraEnv = null, dropEnv = null, clone = null, reporting = null, multiplexer = null,
|
|
716
756
|
id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt, context = null, multiplexerInstall = null,
|
|
717
757
|
}) {
|
|
@@ -731,6 +771,8 @@ export class SessionManager extends EventEmitter {
|
|
|
731
771
|
cols: clampDimension(cols, 120, 2, 1000),
|
|
732
772
|
rows: clampDimension(rows, 32, 1, 500),
|
|
733
773
|
name,
|
|
774
|
+
defaultName,
|
|
775
|
+
named,
|
|
734
776
|
reportToken,
|
|
735
777
|
resume,
|
|
736
778
|
task,
|
|
@@ -745,7 +787,9 @@ export class SessionManager extends EventEmitter {
|
|
|
745
787
|
throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
|
|
746
788
|
}
|
|
747
789
|
|
|
790
|
+
this._keepCardName(session);
|
|
748
791
|
session.on('changed', () => {
|
|
792
|
+
this._keepCardName(session);
|
|
749
793
|
if (this.sessions.has(id)) this.emit('event', { type: 'session.updated', session: session.toJSON() });
|
|
750
794
|
});
|
|
751
795
|
session.on('warning', (msg) => console.warn(`[session ${id}] ${msg}`));
|
package/src/manager/session.mjs
CHANGED
|
@@ -6,8 +6,7 @@ import { EventEmitter } from 'node:events';
|
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
7
|
import headless from '@xterm/headless';
|
|
8
8
|
import serializeAddon from '@xterm/addon-serialize';
|
|
9
|
-
import {
|
|
10
|
-
import { loadPty } from './pty.mjs';
|
|
9
|
+
import { loadPty, closeConsole, releasePty } from './pty.mjs';
|
|
11
10
|
import { sessionLabel } from '../../web/activity-favicon.js';
|
|
12
11
|
|
|
13
12
|
const { Terminal } = headless;
|
|
@@ -68,6 +67,8 @@ export class Session extends EventEmitter {
|
|
|
68
67
|
* @param {number} opts.cols
|
|
69
68
|
* @param {number} opts.rows
|
|
70
69
|
* @param {string} [opts.name]
|
|
70
|
+
* @param {string} [opts.defaultName] the name a card has when nobody named it
|
|
71
|
+
* @param {boolean} [opts.named] the name was given, so it is kept for the tool session
|
|
71
72
|
* @param {string|null} [opts.resume] id of the tool's own session being resumed
|
|
72
73
|
* @param {string|null} [opts.task] "install" for a package install, "upgrade" for the manager's own, "clone" for a GitHub clone, else null
|
|
73
74
|
* @param {{id: string, label: string}|null} [opts.account] the tool sign-in the session runs under
|
|
@@ -84,7 +85,11 @@ export class Session extends EventEmitter {
|
|
|
84
85
|
super();
|
|
85
86
|
this.id = opts.id;
|
|
86
87
|
this.provider = opts.provider;
|
|
87
|
-
this.
|
|
88
|
+
this.defaultName = cleanName(opts.defaultName) || opts.provider.tool;
|
|
89
|
+
this.name = cleanName(opts.name) || this.defaultName;
|
|
90
|
+
this.named = opts.named === true && this.name !== this.defaultName;
|
|
91
|
+
/** Someone gave or changed this card's name, its default included. */
|
|
92
|
+
this.nameChosen = opts.named === true;
|
|
88
93
|
this.resume = opts.resume ?? null;
|
|
89
94
|
this.task = opts.task ?? null;
|
|
90
95
|
this.account = opts.account ?? null;
|
|
@@ -98,6 +103,7 @@ export class Session extends EventEmitter {
|
|
|
98
103
|
this.activityIdleMs = opts.activityIdleMs ?? 2500;
|
|
99
104
|
this.doneAgentLingerMs = opts.doneAgentLingerMs ?? 15000;
|
|
100
105
|
this.killGraceMs = opts.killGraceMs ?? 4000;
|
|
106
|
+
this._killed = false;
|
|
101
107
|
this.reportingTimeoutMs = opts.reportingTimeoutMs ?? 30000;
|
|
102
108
|
this.reporting = REPORTING_STATES.has(opts.reporting?.state) ? { state: opts.reporting.state, reason: opts.reporting.reason ?? null } : null;
|
|
103
109
|
this._reportingTimer = null;
|
|
@@ -198,6 +204,7 @@ export class Session extends EventEmitter {
|
|
|
198
204
|
useConpty: true,
|
|
199
205
|
});
|
|
200
206
|
this.pty = proc;
|
|
207
|
+
this._killed = false;
|
|
201
208
|
proc.onData((data) => { if (this.pty === proc) this._onData(data); });
|
|
202
209
|
proc.onExit(({ exitCode, signal }) => { if (this.pty === proc) this._onExit(exitCode, signal); });
|
|
203
210
|
}
|
|
@@ -244,6 +251,7 @@ export class Session extends EventEmitter {
|
|
|
244
251
|
|
|
245
252
|
_onExit(exitCode, signal) {
|
|
246
253
|
clearTimeout(this._killTimer);
|
|
254
|
+
if (process.platform === 'win32') releasePty(this.pty);
|
|
247
255
|
this._resolveExited();
|
|
248
256
|
if (this.disposed) return;
|
|
249
257
|
this.exitedAt ??= new Date().toISOString();
|
|
@@ -412,16 +420,18 @@ export class Session extends EventEmitter {
|
|
|
412
420
|
|
|
413
421
|
/**
|
|
414
422
|
* End the process. On macOS and Linux: hang-up first, force after a grace
|
|
415
|
-
* period. On Windows:
|
|
416
|
-
* multiplexer's
|
|
417
|
-
* it.
|
|
418
|
-
* console's process list, and when that helper fails it waits a fixed
|
|
419
|
-
* five seconds.
|
|
423
|
+
* period. On Windows: close the session's console, which ends every process
|
|
424
|
+
* attached to it; a multiplexer's server and anything started detached
|
|
425
|
+
* outlive it.
|
|
420
426
|
*/
|
|
421
427
|
kill({ graceMs = this.killGraceMs } = {}) {
|
|
422
428
|
if (this.status !== 'running') return;
|
|
423
429
|
if (process.platform === 'win32') {
|
|
424
|
-
|
|
430
|
+
// At most once per pty: node-pty 1.2.0-beta.15 closes the pseudoconsole again on a second close before the
|
|
431
|
+
// exit watcher runs, which corrupts this process's heap (microsoft/node-pty#983).
|
|
432
|
+
if (this._killed) return;
|
|
433
|
+
this._killed = true;
|
|
434
|
+
closeConsole(this.pty);
|
|
425
435
|
return;
|
|
426
436
|
}
|
|
427
437
|
const force = () => { try { this.pty.kill('SIGKILL'); } catch { /* gone */ } };
|
|
@@ -431,15 +441,6 @@ export class Session extends EventEmitter {
|
|
|
431
441
|
this._killTimer.unref?.();
|
|
432
442
|
}
|
|
433
443
|
|
|
434
|
-
_killWindowsTree() {
|
|
435
|
-
const fallback = () => {
|
|
436
|
-
if (this.status === 'running') { try { this.pty.kill(); } catch { /* gone */ } }
|
|
437
|
-
};
|
|
438
|
-
const pid = this.pty.pid;
|
|
439
|
-
if (!pid) return fallback();
|
|
440
|
-
killWindowsTree(pid, (err) => { if (err) fallback(); }, { tree: !this.multiplexer });
|
|
441
|
-
}
|
|
442
|
-
|
|
443
444
|
/**
|
|
444
445
|
* Detach everything. A still-running process gets the normal hang-up and
|
|
445
446
|
* is force-killed after the grace period if it ignores it.
|
|
@@ -461,6 +462,8 @@ export class Session extends EventEmitter {
|
|
|
461
462
|
const clean = cleanName(name);
|
|
462
463
|
if (!clean) return;
|
|
463
464
|
this.name = clean;
|
|
465
|
+
this.named = clean !== this.defaultName;
|
|
466
|
+
this.nameChosen = true;
|
|
464
467
|
this._changed();
|
|
465
468
|
}
|
|
466
469
|
|
package/src/mcp/server.mjs
CHANGED
|
@@ -84,6 +84,13 @@ const TOOLS = [
|
|
|
84
84
|
inputSchema: { type: 'object', properties: { session_id: sessionId }, required: ['session_id'], additionalProperties: false },
|
|
85
85
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
name: 'fireworks',
|
|
89
|
+
title: 'Fireworks over the Yard',
|
|
90
|
+
description: 'Launch fireworks over the Yard on every screen connected to Agent Guild. Use it to celebrate something the person cares about, such as tests, a build or a release that passed. Says how many pages saw them.',
|
|
91
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
93
|
+
},
|
|
87
94
|
];
|
|
88
95
|
|
|
89
96
|
const INPUT_TOOL = {
|
|
@@ -309,6 +316,10 @@ export function createToolHandlers(client, { allowInput = false } = {}) {
|
|
|
309
316
|
const { session } = await client.api(`/sessions/${checkId(id)}/stop`, { method: 'POST', session: id });
|
|
310
317
|
return text({ stopped: true, session: summarize(session) });
|
|
311
318
|
},
|
|
319
|
+
async fireworks() {
|
|
320
|
+
const { pages } = await client.api('/yard/fireworks', { method: 'POST' });
|
|
321
|
+
return text({ launched: true, pages, ...(pages ? {} : { message: 'No page is open to see them. The person can open Agent Guild and choose the Yard.' }) });
|
|
322
|
+
},
|
|
312
323
|
};
|
|
313
324
|
if (allowInput) {
|
|
314
325
|
handlers.send_input = async ({ session_id: id, text: typed, submit = true }) => {
|
package/web/app.js
CHANGED
|
@@ -2293,8 +2293,8 @@ function buildProvider(provider) {
|
|
|
2293
2293
|
existing.addEventListener('click', (event) => showHistory(provider, false, event.currentTarget));
|
|
2294
2294
|
node.querySelector('.launch-flags').addEventListener('click', (event) => showFlags(provider, event.currentTarget));
|
|
2295
2295
|
const memory = node.querySelector('.memory-link');
|
|
2296
|
-
|
|
2297
|
-
|
|
2296
|
+
const historyLink = !provider.memorySource && provider.historySource === 'antigravity';
|
|
2297
|
+
memory.hidden = !provider.available || (!provider.memorySource && !historyLink);
|
|
2298
2298
|
memory.textContent = historyLink ? 'History' : 'Memory';
|
|
2299
2299
|
memory.title = historyLink ? `Read ${provider.tool}'s saved conversations` : `See what ${provider.tool} remembers about the working folder`;
|
|
2300
2300
|
memory.addEventListener('click', (event) => historyLink ? showHistory(provider, true, event.currentTarget) : showMemory(provider, event.currentTarget));
|
|
@@ -3970,7 +3970,7 @@ async function startSession(provider, card, { resume, cwd, account = selectedAcc
|
|
|
3970
3970
|
({ session } = await api('POST', '/sessions', body));
|
|
3971
3971
|
} catch (err) {
|
|
3972
3972
|
if (err.code !== 'bad_cwd' || !cwd) throw err;
|
|
3973
|
-
if (resume && provider.
|
|
3973
|
+
if (resume && provider.historySource === 'antigravity') {
|
|
3974
3974
|
const chosen = (await ask(`${cwd} no longer exists. Choose a working folder to resume this conversation:`, { ok: 'Resume', value: working }))?.trim();
|
|
3975
3975
|
if (!chosen) return;
|
|
3976
3976
|
({ session } = await api('POST', '/sessions', { ...body, cwd: chosen }));
|
|
@@ -4011,20 +4011,22 @@ function renderHistoryPreview(view) {
|
|
|
4011
4011
|
$('history-preview-title').textContent = view.target?.title || 'Select a conversation';
|
|
4012
4012
|
$('history-preview-refresh').disabled = !view.target || view.loading;
|
|
4013
4013
|
$('history-preview-more').hidden = !view.nextCursor;
|
|
4014
|
-
paintPending($('history-preview-more'), view.loading, 'Read
|
|
4014
|
+
paintPending($('history-preview-more'), view.loading, 'Read earlier', 'Reading…');
|
|
4015
4015
|
const notes = [];
|
|
4016
4016
|
if (view.error) notes.push(view.error);
|
|
4017
4017
|
else if (view.loading) notes.push('Reading saved messages…');
|
|
4018
|
-
else if (view.target && !view.messages.length) notes.push(view.nextCursor ? 'No visible messages in this part.
|
|
4018
|
+
else if (view.target && !view.messages.length) notes.push(view.nextCursor ? 'No visible messages in this part. Read earlier messages above.' : 'No visible messages in this conversation.');
|
|
4019
4019
|
else if (!view.target) notes.push('Read a saved exchange, then resume when you want to continue it.');
|
|
4020
|
-
if (view.omitted) notes.push('Some unreadable
|
|
4020
|
+
if (view.omitted) notes.push('Some unreadable records were omitted.');
|
|
4021
|
+
if (view.truncated) notes.push('Earlier messages cannot be shown: a record before these is too large or unreadable.');
|
|
4021
4022
|
if (view.incomplete) notes.push('The last record is incomplete. Refresh after the conversation finishes writing.');
|
|
4022
4023
|
$('history-preview-note').textContent = notes.join(' ');
|
|
4023
4024
|
const messages = $('history-messages');
|
|
4024
|
-
const
|
|
4025
|
+
const fromBottom = messages.scrollHeight - messages.scrollTop;
|
|
4026
|
+
const first = messages.childElementCount === 0;
|
|
4025
4027
|
messages.replaceChildren(...view.messages.map((message) => el('article', 'history-message',
|
|
4026
4028
|
el('h4', null, message.role === 'user' ? 'You' : 'Assistant'), el('p', null, message.text))));
|
|
4027
|
-
messages.scrollTop =
|
|
4029
|
+
messages.scrollTop = first ? messages.scrollHeight : messages.scrollHeight - fromBottom;
|
|
4028
4030
|
for (const node of $('history-list').querySelectorAll('.history-preview-open')) node.setAttribute('aria-current', String(node.dataset.id === view.target?.id));
|
|
4029
4031
|
}
|
|
4030
4032
|
|
|
@@ -4090,10 +4092,8 @@ async function loadHistory() {
|
|
|
4090
4092
|
renderHistory();
|
|
4091
4093
|
try {
|
|
4092
4094
|
const query = new URLSearchParams({ account: accountId, limit: HISTORY_LIMIT });
|
|
4093
|
-
if (
|
|
4094
|
-
|
|
4095
|
-
if ($('history-filter').value.trim()) query.set('q', $('history-filter').value.trim());
|
|
4096
|
-
}
|
|
4095
|
+
if ($('history-here').checked && $('cwd').value.trim()) query.set('cwd', $('cwd').value.trim());
|
|
4096
|
+
if ($('history-filter').value.trim()) query.set('q', $('history-filter').value.trim());
|
|
4097
4097
|
const { history } = await api('GET', `/providers/${providerId}/history?${query}`);
|
|
4098
4098
|
if (request !== historyView.request) return;
|
|
4099
4099
|
historyView.snapshot = history;
|
|
@@ -4107,27 +4107,18 @@ async function loadHistory() {
|
|
|
4107
4107
|
}
|
|
4108
4108
|
|
|
4109
4109
|
function historyFiltersChanged() {
|
|
4110
|
-
if (historyProvider()?.
|
|
4110
|
+
if (historyProvider()?.historySource) {
|
|
4111
4111
|
historyView.snapshot = null;
|
|
4112
4112
|
historyPreview.clear();
|
|
4113
4113
|
loadHistory();
|
|
4114
4114
|
} else renderHistory();
|
|
4115
4115
|
}
|
|
4116
4116
|
|
|
4117
|
-
function historyText(entry) {
|
|
4118
|
-
return `${entry.title ?? ''}\n${entry.cwd ?? ''}\n${entry.id}`.toLowerCase();
|
|
4119
|
-
}
|
|
4120
|
-
|
|
4121
4117
|
function folderName(dir) {
|
|
4122
4118
|
const parts = dir.replace(/[\\/]+$/, '').split(/[\\/]/);
|
|
4123
4119
|
return parts.at(-1) || dir;
|
|
4124
4120
|
}
|
|
4125
4121
|
|
|
4126
|
-
function sameFolder(a, b) {
|
|
4127
|
-
const clean = (dir) => (dir || '').replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase();
|
|
4128
|
-
return clean(a) === clean(b);
|
|
4129
|
-
}
|
|
4130
|
-
|
|
4131
4122
|
function historyEntry(id) {
|
|
4132
4123
|
return historyView.snapshot?.sessions.find((entry) => entry.id === id) ?? null;
|
|
4133
4124
|
}
|
|
@@ -4138,7 +4129,7 @@ async function resumeFromHistory(provider, id, cwd) {
|
|
|
4138
4129
|
closeHistory({ focusOpener: false });
|
|
4139
4130
|
openPanel(running.id);
|
|
4140
4131
|
} else {
|
|
4141
|
-
if (provider.
|
|
4132
|
+
if (provider.historySource === 'antigravity' && !cwd) {
|
|
4142
4133
|
const workspaces = historyEntry(id)?.workspaces ?? [];
|
|
4143
4134
|
const reason = workspaces.length > 1 ? `This conversation has several workspaces:\n${workspaces.join('\n')}\n\n` : 'The saved working folder is unknown.\n\n';
|
|
4144
4135
|
cwd = (await ask(`${reason}Choose a working folder to resume in:`, { ok: 'Resume', value: $('cwd').value.trim() }))?.trim();
|
|
@@ -4164,27 +4155,28 @@ function buildHistoryRow(id) {
|
|
|
4164
4155
|
|
|
4165
4156
|
function updateHistoryRow(node, provider, entry) {
|
|
4166
4157
|
const running = runningOn(provider.id, historyView.accountId, entry.id);
|
|
4167
|
-
|
|
4158
|
+
const label = entry.name ?? entry.title;
|
|
4159
|
+
node.classList.toggle('untitled', !label);
|
|
4168
4160
|
node.classList.toggle('running', Boolean(running));
|
|
4169
4161
|
const title = node.querySelector('.history-title');
|
|
4170
|
-
title.title =
|
|
4162
|
+
title.title = label ?? '';
|
|
4171
4163
|
if (provider.historyDetails) {
|
|
4172
4164
|
let preview = title.querySelector('button');
|
|
4173
4165
|
if (!preview) {
|
|
4174
4166
|
preview = button('', () => {
|
|
4175
4167
|
const current = historyEntry(entry.id);
|
|
4176
|
-
if (current) historyPreview.select({ providerId: provider.id, accountId: historyView.accountId, id: entry.id, title: current.title ?? 'Untitled session' });
|
|
4168
|
+
if (current) historyPreview.select({ providerId: provider.id, accountId: historyView.accountId, id: entry.id, title: current.name ?? current.title ?? 'Untitled session' });
|
|
4177
4169
|
}, 'history-preview-open');
|
|
4178
4170
|
preview.dataset.id = entry.id;
|
|
4179
4171
|
title.replaceChildren(preview);
|
|
4180
4172
|
}
|
|
4181
|
-
preview.textContent =
|
|
4173
|
+
preview.textContent = label ?? 'Untitled session';
|
|
4182
4174
|
preview.setAttribute('aria-current', String(historyPreview.state.target?.id === entry.id));
|
|
4183
|
-
} else title.textContent =
|
|
4175
|
+
} else title.textContent = label ?? 'Untitled session';
|
|
4184
4176
|
const meta = node.querySelector('.history-meta');
|
|
4185
4177
|
const when = entry.updatedAt ? `updated ${relativeTime(entry.updatedAt)}` : '';
|
|
4186
|
-
const folder = entry.cwd ? folderName(entry.cwd) : provider.
|
|
4187
|
-
meta.textContent = [folder, when, running && `open in Agent Guild as ${running.name}`].filter(Boolean).join(' · ');
|
|
4178
|
+
const folder = entry.cwd ? folderName(entry.cwd) : provider.historySource === 'antigravity' ? (entry.workspaces?.length > 1 ? `${entry.workspaces.length} workspaces` : 'Folder unknown') : null;
|
|
4179
|
+
meta.textContent = [entry.name && entry.title !== entry.name ? entry.title : null, folder, when, running && `open in Agent Guild as ${running.name}`].filter(Boolean).join(' · ');
|
|
4188
4180
|
meta.title = [...(entry.workspaces ?? [entry.cwd]), entry.startedAt && `started ${new Date(entry.startedAt).toLocaleString()}`].filter(Boolean).join('\n');
|
|
4189
4181
|
const action = node.querySelector('.history-resume');
|
|
4190
4182
|
const key = sessionActionKey(provider.id, historyView.accountId, entry.id);
|
|
@@ -4193,7 +4185,7 @@ function updateHistoryRow(node, provider, entry) {
|
|
|
4193
4185
|
action.title = running
|
|
4194
4186
|
? `This session is running in Agent Guild as "${running.name}". Open it instead of resuming it twice.`
|
|
4195
4187
|
: `Resume this ${provider.tool} session${entry.cwd ? ` in ${entry.cwd}` : ''}`;
|
|
4196
|
-
action.setAttribute('aria-label', `${action.textContent} ${
|
|
4188
|
+
action.setAttribute('aria-label', `${action.textContent} ${label ?? entry.id}`);
|
|
4197
4189
|
}
|
|
4198
4190
|
|
|
4199
4191
|
/** Rows are kept and updated in place, so a refresh never drops keyboard focus from a row's buttons. */
|
|
@@ -4222,9 +4214,8 @@ function renderHistory() {
|
|
|
4222
4214
|
const here = $('history-here');
|
|
4223
4215
|
here.disabled = !working;
|
|
4224
4216
|
here.parentElement.title = working ? `Only sessions started in ${working}` : 'Set a working folder above to filter by it';
|
|
4225
|
-
const serverFiltered =
|
|
4226
|
-
const
|
|
4227
|
-
const shown = provider.historyDetails ? all : all.filter((entry) => (!filter || historyText(entry).includes(filter)) && (!here.checked || here.disabled || sameFolder(entry.cwd, working)));
|
|
4217
|
+
const serverFiltered = Boolean(filter || (here.checked && !here.disabled));
|
|
4218
|
+
const shown = snapshot?.sessions ?? [];
|
|
4228
4219
|
const parts = [];
|
|
4229
4220
|
if ((provider.accounts?.length ?? 0) > 1 && account) parts.push(`${account.label} account`);
|
|
4230
4221
|
if (snapshot && !snapshot.error) {
|
|
@@ -4239,14 +4230,11 @@ function renderHistory() {
|
|
|
4239
4230
|
if (!provider.historySource) note = `Agent Guild cannot list ${provider.tool}'s sessions. Enter the id of one to resume it.`;
|
|
4240
4231
|
else if (historyView.loading && !snapshot) note = `Reading ${provider.tool}'s sessions…`;
|
|
4241
4232
|
else if (snapshot?.error) note = `Sessions could not be read: ${snapshot.error}`;
|
|
4242
|
-
else if (snapshot &&
|
|
4233
|
+
else if (snapshot && shown.length === 0 && !serverFiltered) note = `No ${provider.tool} sessions were found${account && account.id !== 'default' ? ` for the ${account.label} account` : ''}.`;
|
|
4243
4234
|
else if (snapshot && shown.length === 0) note = 'No session matches the filter.';
|
|
4244
4235
|
if (snapshot?.note) note = [note, snapshot.note].filter(Boolean).join(' ');
|
|
4245
|
-
if (snapshot?.total >
|
|
4246
|
-
|
|
4247
|
-
? `Showing ${all.length} of ${snapshot.total} sessions. Narrow the filter to find older conversations.`
|
|
4248
|
-
: `Loaded the newest ${all.length} of ${snapshot.total} sessions. Filtering searches these loaded sessions.`;
|
|
4249
|
-
note = [note, limitNote].filter(Boolean).join(' ');
|
|
4236
|
+
if (snapshot?.total > shown.length) {
|
|
4237
|
+
note = [note, `Showing ${shown.length} of ${snapshot.total} sessions. Narrow the filter to find older conversations.`].filter(Boolean).join(' ');
|
|
4250
4238
|
}
|
|
4251
4239
|
$('history-note').textContent = note;
|
|
4252
4240
|
$('history-note').hidden = !note;
|
|
@@ -5305,6 +5293,7 @@ async function loadView(view) {
|
|
|
5305
5293
|
const request = { key, stamp, value: before?.stamp === stamp ? before.value : null, error: null, loading: true };
|
|
5306
5294
|
githubPick.data[view] = request;
|
|
5307
5295
|
renderGitHubViews();
|
|
5296
|
+
const watching = view === 'actions' ? new Set(runWatch.runs.values()) : null;
|
|
5308
5297
|
let value = null;
|
|
5309
5298
|
let error = null;
|
|
5310
5299
|
try {
|
|
@@ -5319,7 +5308,7 @@ async function loadView(view) {
|
|
|
5319
5308
|
renderGitHubViews();
|
|
5320
5309
|
if (view === 'actions') scheduleRuns();
|
|
5321
5310
|
if (view === 'actions' && value) {
|
|
5322
|
-
runWatchSeen(repo, value.runs);
|
|
5311
|
+
runWatchSeen(repo, value.runs, watching);
|
|
5323
5312
|
deliverRunWatch();
|
|
5324
5313
|
}
|
|
5325
5314
|
}
|
|
@@ -5556,11 +5545,12 @@ function endWatch(key, outcome) {
|
|
|
5556
5545
|
runWatch.outcomes.push({ repo: watch.repo.fullName, name: watch.name, title: watch.title, url: watch.url, ...outcome });
|
|
5557
5546
|
}
|
|
5558
5547
|
|
|
5559
|
-
|
|
5548
|
+
/** Only the watches there were when the request for `runs` began: a later one may be for a run newer than those listed. */
|
|
5549
|
+
function runWatchSeen(repo, runs, watching = new Set(runWatch.runs.values())) {
|
|
5560
5550
|
const key = repoKey(repo);
|
|
5561
5551
|
let changed = false;
|
|
5562
5552
|
for (const [id, watch] of runWatch.runs) {
|
|
5563
|
-
if (repoKey(watch.repo) !== key) continue;
|
|
5553
|
+
if (repoKey(watch.repo) !== key || !watching.has(watch)) continue;
|
|
5564
5554
|
const run = runs.find((r) => r.id === watch.id);
|
|
5565
5555
|
if (run && run.status !== 'completed') continue;
|
|
5566
5556
|
changed = true;
|
|
@@ -5635,12 +5625,13 @@ function pollRunWatch() {
|
|
|
5635
5625
|
runWatch.polling = (async () => {
|
|
5636
5626
|
if (!state.connected || !runWatch.runs.size) return;
|
|
5637
5627
|
if (!state.github) await api('GET', '/github').then(({ github }) => setGitHub(github), () => {});
|
|
5628
|
+
const watching = new Set(runWatch.runs.values());
|
|
5638
5629
|
const repos = new Map();
|
|
5639
|
-
for (const watch of
|
|
5630
|
+
for (const watch of watching) repos.set(repoKey(watch.repo), watch.repo);
|
|
5640
5631
|
const accounts = usableGitHubAccounts();
|
|
5641
5632
|
await Promise.all([...repos.values()].filter((repo) => accounts.some((a) => a.id === repo.accountId)).map(async (repo) => {
|
|
5642
5633
|
try {
|
|
5643
|
-
runWatchSeen(repo, (await api('GET', `${repoPath(repo)}/actions`)).runs);
|
|
5634
|
+
runWatchSeen(repo, (await api('GET', `${repoPath(repo)}/actions`)).runs, watching);
|
|
5644
5635
|
} catch (err) {
|
|
5645
5636
|
if (err instanceof AuthError) showAuth(err.message);
|
|
5646
5637
|
}
|
|
@@ -7750,6 +7741,8 @@ function connectEvents() {
|
|
|
7750
7741
|
} else if (msg.type === 'yard-art.updated') {
|
|
7751
7742
|
state.yardArt = msg.yardArt;
|
|
7752
7743
|
notifyViews();
|
|
7744
|
+
} else if (msg.type === 'yard.fireworks') {
|
|
7745
|
+
yardUi?.celebrate();
|
|
7753
7746
|
} else if (msg.type === 'news.updated') {
|
|
7754
7747
|
loadNews();
|
|
7755
7748
|
} else if (msg.type === 'github.updated') {
|
package/web/history-preview.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// can be exercised with explicitly released promises, without timers or a browser.
|
|
3
3
|
export function createHistoryPreview({ read, changed, authError = () => {} }) {
|
|
4
4
|
let request = 0;
|
|
5
|
-
const state = { target: null, messages: [], nextCursor: null, loading: false, error: null, incomplete: false, omitted: false };
|
|
5
|
+
const state = { target: null, messages: [], nextCursor: null, loading: false, error: null, incomplete: false, omitted: false, truncated: false };
|
|
6
6
|
function clear(target = null) {
|
|
7
7
|
request++;
|
|
8
|
-
Object.assign(state, { target, messages: [], nextCursor: null, loading: false, error: null, incomplete: false, omitted: false });
|
|
8
|
+
Object.assign(state, { target, messages: [], nextCursor: null, loading: false, error: null, incomplete: false, omitted: false, truncated: false });
|
|
9
9
|
changed(state);
|
|
10
10
|
}
|
|
11
11
|
async function load(more = false) {
|
|
@@ -14,19 +14,19 @@ export function createHistoryPreview({ read, changed, authError = () => {} }) {
|
|
|
14
14
|
const target = state.target;
|
|
15
15
|
const cursor = more ? state.nextCursor : null;
|
|
16
16
|
Object.assign(state, { loading: true, error: null });
|
|
17
|
-
if (!more) Object.assign(state, { messages: [], nextCursor: null, incomplete: false, omitted: false });
|
|
17
|
+
if (!more) Object.assign(state, { messages: [], nextCursor: null, incomplete: false, omitted: false, truncated: false });
|
|
18
18
|
changed(state);
|
|
19
19
|
try {
|
|
20
20
|
const detail = await read(target, cursor);
|
|
21
21
|
if (request !== ticket) return;
|
|
22
|
-
Object.assign(state, { messages: more ? [...
|
|
23
|
-
nextCursor: detail.nextCursor, incomplete: detail.incomplete, omitted: state.omitted || detail.omitted });
|
|
22
|
+
Object.assign(state, { messages: more ? [...detail.messages, ...state.messages] : detail.messages,
|
|
23
|
+
nextCursor: detail.nextCursor, incomplete: more ? state.incomplete : detail.incomplete, omitted: state.omitted || detail.omitted, truncated: detail.truncated === true });
|
|
24
24
|
} catch (err) {
|
|
25
25
|
if (request !== ticket) return;
|
|
26
26
|
authError(err);
|
|
27
27
|
state.error = err.message || 'The conversation could not be read.';
|
|
28
|
-
// A
|
|
29
|
-
if (err.code === 'history_changed') state
|
|
28
|
+
// A changed conversation drops what was read; other errors can retry the same page.
|
|
29
|
+
if (err.code === 'history_changed') Object.assign(state, { messages: [], nextCursor: null, incomplete: false, omitted: false, truncated: false });
|
|
30
30
|
} finally {
|
|
31
31
|
if (request === ticket) { state.loading = false; changed(state); }
|
|
32
32
|
}
|
package/web/index.html
CHANGED
|
@@ -650,8 +650,8 @@
|
|
|
650
650
|
<button id="history-preview-refresh" class="btn" type="button">Refresh</button>
|
|
651
651
|
</div>
|
|
652
652
|
<p id="history-preview-note" class="models-note" role="status"></p>
|
|
653
|
+
<button id="history-preview-more" class="btn" type="button" hidden>Read earlier</button>
|
|
653
654
|
<div id="history-messages" class="history-messages" tabindex="0" aria-label="Saved messages"></div>
|
|
654
|
-
<button id="history-preview-more" class="btn" type="button" hidden>Read more</button>
|
|
655
655
|
</section>
|
|
656
656
|
</div>
|
|
657
657
|
<form id="history-form" class="history-form">
|