@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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const NODE_COMMANDS = new Set(['node', 'npm', 'npx', 'corepack']);
|
|
6
|
+
const quote = (text) => `'${text.replace(/'/g, "'\\''")}'`;
|
|
7
|
+
const cmdLauncher = (entry) => `@"%~dp0..\\${entry}" %*\r\n`;
|
|
8
|
+
const shLauncher = (target, node = null) => `#!/bin/sh\nexec ${node ? `${quote(node)} ` : ''}${quote(target)} "$@"\n`;
|
|
9
|
+
const gitBashLauncher = (name) => `#!/bin/sh\nexec "$(dirname "$0")/../${name}" "$@"\n`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The installers put only <prefix>/launcher on PATH, never the Node.js they
|
|
13
|
+
* bring. This writes a command there for each one npm installed in that
|
|
14
|
+
* prefix, run on that Node.js, and removes those npm no longer has. Without
|
|
15
|
+
* that folder the running Node.js is not an installer's, and nothing changes.
|
|
16
|
+
*/
|
|
17
|
+
export function refreshLaunchers({ execPath = process.execPath, platform = process.platform } = {}) {
|
|
18
|
+
const win = platform === 'win32';
|
|
19
|
+
const prefix = fs.realpathSync(win ? path.dirname(execPath) : path.dirname(path.dirname(execPath)));
|
|
20
|
+
const bin = win ? prefix : path.join(prefix, 'bin');
|
|
21
|
+
const dir = path.join(prefix, 'launcher');
|
|
22
|
+
if (!fs.existsSync(dir)) return;
|
|
23
|
+
const wanted = new Map();
|
|
24
|
+
for (const entry of fs.readdirSync(bin)) {
|
|
25
|
+
const name = win ? entry.match(/^(.+)\.cmd$/i)?.[1] : entry;
|
|
26
|
+
if (!name || NODE_COMMANDS.has(name.toLowerCase())) continue;
|
|
27
|
+
if (win) {
|
|
28
|
+
wanted.set(entry, cmdLauncher(entry));
|
|
29
|
+
if (fs.existsSync(path.join(bin, name))) wanted.set(name, gitBashLauncher(name));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const target = path.join(bin, entry);
|
|
33
|
+
const start = Buffer.alloc(200);
|
|
34
|
+
let head = '';
|
|
35
|
+
try {
|
|
36
|
+
const fd = fs.openSync(target, 'r');
|
|
37
|
+
try { head = start.toString('utf8', 0, fs.readSync(fd, start, 0, start.length, 0)).split('\n')[0]; } finally { fs.closeSync(fd); }
|
|
38
|
+
} catch { continue; }
|
|
39
|
+
wanted.set(entry, shLauncher(target, /^#!.*\bnode\b/.test(head) ? path.join(bin, 'node') : null));
|
|
40
|
+
}
|
|
41
|
+
for (const entry of fs.readdirSync(dir)) if (!wanted.has(entry)) fs.rmSync(path.join(dir, entry), { force: true });
|
|
42
|
+
for (const [entry, text] of wanted) {
|
|
43
|
+
const file = path.join(dir, entry);
|
|
44
|
+
let current = null;
|
|
45
|
+
try { current = fs.readFileSync(file, 'utf8'); } catch {}
|
|
46
|
+
if (current !== text) fs.writeFileSync(file, text, { mode: 0o755 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The command one of those launchers runs, or `file` itself when it is not exactly one of them. */
|
|
51
|
+
export function launcherTarget(file, platform, fsx) {
|
|
52
|
+
const m = platform === 'win32' ? path.win32 : path.posix;
|
|
53
|
+
const dir = m.dirname(file);
|
|
54
|
+
if (m.basename(dir) !== 'launcher') return file;
|
|
55
|
+
const entry = m.basename(file);
|
|
56
|
+
const text = fsx.readText(file);
|
|
57
|
+
if (platform === 'win32') {
|
|
58
|
+
const target = m.join(m.dirname(dir), entry);
|
|
59
|
+
return text === cmdLauncher(entry) && fsx.isFile(target) ? target : file;
|
|
60
|
+
}
|
|
61
|
+
const bin = m.join(fsx.realpath(m.dirname(dir)), 'bin');
|
|
62
|
+
const target = m.join(bin, entry);
|
|
63
|
+
return (text === shLauncher(target) || text === shLauncher(target, m.join(bin, 'node'))) && fsx.isFile(target) ? target : file;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) refreshLaunchers();
|
package/src/manager/main.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { createManagerServer } from './server.mjs';
|
|
|
19
19
|
import { YardArt } from './yard-art.mjs';
|
|
20
20
|
import { loadPty } from './pty.mjs';
|
|
21
21
|
import { createNotesStore } from './notes.mjs';
|
|
22
|
+
import { createCardNames } from './card-names.mjs';
|
|
22
23
|
import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
|
|
23
24
|
import { SelfUpdate } from './self-update.mjs';
|
|
24
25
|
import { addRuntimePaths, resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
@@ -87,8 +88,9 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
87
88
|
registry.reportingEnabled = (provider) => sessionHooks.enabled(provider);
|
|
88
89
|
registry.reportingNote = (provider) => sessionHooks.note(provider);
|
|
89
90
|
sessionHooks.warm();
|
|
91
|
+
const cardNames = createCardNames(paths.cardNames);
|
|
90
92
|
const manager = new SessionManager({
|
|
91
|
-
registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, store: multiplexerStore,
|
|
93
|
+
registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate, github, sessionHooks, store: multiplexerStore, cardNames,
|
|
92
94
|
});
|
|
93
95
|
environment.setSessionLookup((id) => {
|
|
94
96
|
let session;
|
|
@@ -99,7 +101,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
99
101
|
if (event.type === 'session.removed') environment.forgetSession(event.sessionId);
|
|
100
102
|
});
|
|
101
103
|
const usage = new UsageMonitor({ registry, env: baseEnv });
|
|
102
|
-
const history = new SessionHistory({ registry, env: baseEnv });
|
|
104
|
+
const history = new SessionHistory({ registry, env: baseEnv, names: cardNames });
|
|
103
105
|
const memory = new AgentMemory({ env: baseEnv, resolveCwd: (cwd) => manager.resolveCwd(cwd) });
|
|
104
106
|
const instructions = new InstructionFiles({ env: baseEnv, resolveCwd: (cwd) => manager.resolveCwd(cwd) });
|
|
105
107
|
const modelStats = new ModelStats({ registry });
|
|
@@ -12,7 +12,7 @@ import { createHash } from 'node:crypto';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, npmSpawnSpec, runSpec } from './command-resolver.mjs';
|
|
14
14
|
import { compareVersions, probeVersion, fetchManifest, latestVersion, latestReleaseTag, brewVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
|
|
15
|
-
import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, homeRelative, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
|
|
15
|
+
import { CHANNEL_LABELS, classifyInstall, commandMtime, expandHome, formatCommand, homeRelative, knownLaunchers, listInstallations, platformDependency, updateHelpAccepted } from './install-channels.mjs';
|
|
16
16
|
import { copyChannel, linkOnPath, pluginVersion, readPlugin } from './docker-plugin.mjs';
|
|
17
17
|
import { weavePaths } from './shell-env.mjs';
|
|
18
18
|
import { detectShells, fallbackShell } from './shells.mjs';
|
|
@@ -20,6 +20,7 @@ import { RUNNER, encodePlan } from './uninstall.mjs';
|
|
|
20
20
|
import { paths } from './config.mjs';
|
|
21
21
|
import { MultiplexerRegistry } from './multiplexers.mjs';
|
|
22
22
|
import { reconcileHerdrPath } from './multiplexer-paths.mjs';
|
|
23
|
+
import { refreshLaunchers } from './launchers.mjs';
|
|
23
24
|
import { LaunchFlags, normalizeLaunchFlags } from './launch-flags.mjs';
|
|
24
25
|
|
|
25
26
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -32,10 +33,6 @@ const FAILED_PROBE_TTL_MS = 5 * 60 * 1000;
|
|
|
32
33
|
const PATH_REFRESH_MS = 60 * 1000;
|
|
33
34
|
const INSTALLS_TTL_MS = 30 * 1000;
|
|
34
35
|
|
|
35
|
-
function fileMtime(file) {
|
|
36
|
-
try { return fs.statSync(file).mtimeMs; } catch { return null; }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
36
|
function realPathOf(file) {
|
|
40
37
|
try { return fs.realpathSync.native(file); } catch { return file; }
|
|
41
38
|
}
|
|
@@ -418,7 +415,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
418
415
|
// A listed copy whose file has gone since docker listed it was removed by hand: ask docker again, once. A copy
|
|
419
416
|
// docker lists that was already missing when it answered is not asked about again until the next interval,
|
|
420
417
|
// or every refresh would run docker info (the providers request refreshes on every call).
|
|
421
|
-
const missing = (copies) => copies.filter((c) =>
|
|
418
|
+
const missing = (copies) => copies.filter((c) => commandMtime(c.path, this.platform) === null).map((c) => c.path);
|
|
422
419
|
const gone = Boolean(read && missing(read.copies).some((file) => !read.missing.includes(file)));
|
|
423
420
|
if (!found) {
|
|
424
421
|
if (read) { entry.plugin = null; changed = true; }
|
|
@@ -431,7 +428,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
431
428
|
}
|
|
432
429
|
const channel = found ? this.channelFor(provider, found) : null;
|
|
433
430
|
if (channel?.probe) {
|
|
434
|
-
const mtime =
|
|
431
|
+
const mtime = commandMtime(found, this.platform);
|
|
435
432
|
const ttl = entry.probeOk === true ? VERSION_TTL_MS : FAILED_PROBE_TTL_MS;
|
|
436
433
|
if (force || entry.probePath !== found || entry.probeMtime !== mtime || now - entry.probeAt > ttl) {
|
|
437
434
|
const spec = buildSpawnSpec(channel.update.file, [...channel.update.args, '--help'], this.env, this.platform);
|
|
@@ -448,7 +445,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
448
445
|
const pluginCopies = entry.plugin?.copies ?? [];
|
|
449
446
|
const resolved = provider.versionArgs ? (provider.plugin ? pluginCopies.find((c) => c.active)?.path ?? null : found) : null;
|
|
450
447
|
if (resolved) {
|
|
451
|
-
const mtime =
|
|
448
|
+
const mtime = commandMtime(resolved, this.platform);
|
|
452
449
|
const ttl = entry.installed === null ? FAILED_PROBE_TTL_MS : VERSION_TTL_MS;
|
|
453
450
|
const stale = entry.installedPath !== resolved || entry.installedMtime !== mtime || now - entry.installedAt > ttl;
|
|
454
451
|
if (force || stale) {
|
|
@@ -473,7 +470,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
473
470
|
const installs = provider.plugin ? this._pluginInstalls(provider, pluginCopies) : this.installsFor(provider, found);
|
|
474
471
|
for (const other of provider.versionArgs ? installs.filter((i) => i.resolvedPath !== resolved) : []) {
|
|
475
472
|
const file = other.resolvedPath;
|
|
476
|
-
const mtime =
|
|
473
|
+
const mtime = commandMtime(file, this.platform);
|
|
477
474
|
const cached = entry.copies[file];
|
|
478
475
|
const ttl = cached?.version ? VERSION_TTL_MS : FAILED_PROBE_TTL_MS;
|
|
479
476
|
if (!force && cached && cached.mtime === mtime && now - cached.at <= ttl) {
|
|
@@ -531,6 +528,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
531
528
|
const provider = this.get(id);
|
|
532
529
|
if (!provider) return;
|
|
533
530
|
const before = this.versions.get(id)?.installed ?? null;
|
|
531
|
+
try { refreshLaunchers(); } catch {}
|
|
534
532
|
this.emit('updated');
|
|
535
533
|
await this.refreshVersions({ force: true, ids: [id] });
|
|
536
534
|
const entry = this.versions.get(id);
|
|
@@ -851,7 +849,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
851
849
|
historySource: provider.history === null ? null : typeof provider.history === 'string' ? provider.history : 'command',
|
|
852
850
|
memorySource: provider.memory,
|
|
853
851
|
instructionsSource: provider.instructions,
|
|
854
|
-
historyDetails: provider.history === '
|
|
852
|
+
historyDetails: typeof provider.history === 'string',
|
|
855
853
|
reporting: provider.reporting,
|
|
856
854
|
reportingEnabled: this.reportingEnabled?.(provider) ?? null,
|
|
857
855
|
reportingNote: this.reportingNote?.(provider) ?? null,
|
package/src/manager/pty.mjs
CHANGED
|
@@ -110,3 +110,29 @@ export function loadPty() {
|
|
|
110
110
|
pty = require('node-pty');
|
|
111
111
|
return pty;
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* End a Windows session's pty as node-pty 1.2.0-beta.15's kill() does on the inbox ConPTY, without kill()'s
|
|
116
|
+
* console-list helper: that helper attaches to the console by the shell's pid after kill() has ended the shell, when
|
|
117
|
+
* Windows may have given the pid to another process (microsoft/node-pty#967). node-pty closes the pseudoconsole through
|
|
118
|
+
* its own handle, which ends every process attached to it and does nothing once the process has exited. Like kill(),
|
|
119
|
+
* it waits until the terminal is ready.
|
|
120
|
+
*/
|
|
121
|
+
export function closeConsole(term) {
|
|
122
|
+
term._deferNoArgs(() => {
|
|
123
|
+
const agent = term._agent;
|
|
124
|
+
term._close();
|
|
125
|
+
agent._clearConnectionTimeout();
|
|
126
|
+
agent._pendingPtyInfo = undefined;
|
|
127
|
+
agent._inSocket.readable = false;
|
|
128
|
+
agent._outSocket.readable = false;
|
|
129
|
+
agent._ptyNative.kill(agent._pty, false);
|
|
130
|
+
releasePty(term);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What node-pty leaves open on Windows once a process has exited, by itself or by kill(): the output worker and the input pipe. */
|
|
135
|
+
export function releasePty(term) {
|
|
136
|
+
term._agent._conoutSocketWorker.dispose();
|
|
137
|
+
term._agent._inSocket.destroy();
|
|
138
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -384,6 +384,10 @@ export function createManagerServer({
|
|
|
384
384
|
if (route === '/yard-art' && method === 'GET') return sendJson(res, 200, { yardArt: yardArt.snapshot() });
|
|
385
385
|
if (route === '/yard-art/download' && method === 'POST') return sendJson(res, 202, { yardArt: yardArt.start() });
|
|
386
386
|
if (route === '/yard-art/cancel' && method === 'POST') return sendJson(res, 200, { yardArt: yardArt.cancel() });
|
|
387
|
+
if (route === '/yard/fireworks' && method === 'POST') {
|
|
388
|
+
broadcast({ type: 'yard.fireworks' });
|
|
389
|
+
return sendJson(res, 200, { ok: true, pages: [...eventClients].filter((ws) => !ws.revoked && ws.readyState === ws.OPEN).length });
|
|
390
|
+
}
|
|
387
391
|
|
|
388
392
|
if (route === '/info' && method === 'GET') {
|
|
389
393
|
selfUpdate?.refresh().catch(() => {});
|
|
@@ -10,18 +10,26 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
import { resolveCommand, buildSpawnSpec, runSpec } from './command-resolver.mjs';
|
|
11
11
|
import { toIso } from './usage.mjs';
|
|
12
12
|
import { antigravityUserConversation } from '../report/hooks.mjs';
|
|
13
|
-
import { ANTIGRAVITY_ID, transcriptFile,
|
|
13
|
+
import { ANTIGRAVITY_ID, transcriptFile, transcriptMessage, historyError } from './antigravity-transcript.mjs';
|
|
14
|
+
import {
|
|
15
|
+
JSONL_READERS, DOCKER_VISIBLE, GROK_TURN_ACTIVITY, readJsonlPage, readAntigravityTranscript, readDockerConversation, checkedFile,
|
|
16
|
+
claudePrompt, codexMessage, grokPromptChunk, zstdDecoder,
|
|
17
|
+
} from './history-transcripts.mjs';
|
|
14
18
|
|
|
15
19
|
export const HISTORY_TTL_MS = 5 * 1000;
|
|
16
20
|
export const DEFAULT_LIMIT = 100;
|
|
17
21
|
export const MAX_LIMIT = 500;
|
|
18
22
|
const HEAD_BYTES = 256 * 1024;
|
|
23
|
+
const TAIL_BYTES = 256 * 1024;
|
|
19
24
|
const MAX_TITLE = 120;
|
|
20
25
|
const MAX_ID = 200;
|
|
21
26
|
const MAX_WALK_DEPTH = 4;
|
|
22
27
|
|
|
23
28
|
export class HistoryError extends Error {}
|
|
24
29
|
|
|
30
|
+
export const DETAIL_SOURCES = new Set(['claude', 'codex', 'antigravity', 'grok', 'docker']);
|
|
31
|
+
const TRANSCRIPT_ROOTS = { claude: 'projects', codex: 'sessions', grok: 'sessions' };
|
|
32
|
+
|
|
25
33
|
const TAG_RE = /^<[a-z][a-z0-9_-]*[\s>]/i;
|
|
26
34
|
|
|
27
35
|
function shortPath(file) {
|
|
@@ -29,18 +37,17 @@ function shortPath(file) {
|
|
|
29
37
|
return file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
/** One line of prompt text, or null for an empty
|
|
33
|
-
function
|
|
40
|
+
/** One line of prompt text, or null for an empty one. */
|
|
41
|
+
function lineTitle(text) {
|
|
34
42
|
if (typeof text !== 'string') return null;
|
|
35
43
|
const clean = text.replace(/\s+/g, ' ').trim();
|
|
36
|
-
|
|
37
|
-
return clean.slice(0, MAX_TITLE);
|
|
44
|
+
return clean ? clean.slice(0, MAX_TITLE) : null;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return
|
|
47
|
+
/** For text with no marker saying who wrote it: null also for a tool-inserted (`<tag>…`) message. */
|
|
48
|
+
function promptTitle(text) {
|
|
49
|
+
const title = lineTitle(text);
|
|
50
|
+
return title && !TAG_RE.test(title) ? title : null;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
function parseLines(text) {
|
|
@@ -56,7 +63,7 @@ async function readHead(file, bytes = HEAD_BYTES) {
|
|
|
56
63
|
if (file.endsWith('.zst')) {
|
|
57
64
|
if (typeof zlib.createZstdDecompress !== 'function') return null;
|
|
58
65
|
const source = fs.createReadStream(file);
|
|
59
|
-
const inflate = source.pipe(
|
|
66
|
+
const inflate = source.pipe(zstdDecoder());
|
|
60
67
|
const chunks = [];
|
|
61
68
|
let size = 0;
|
|
62
69
|
try {
|
|
@@ -79,6 +86,21 @@ async function readHead(file, bytes = HEAD_BYTES) {
|
|
|
79
86
|
}
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
/** The whole records in the last bytes of a file, and the offset the first of them starts at. */
|
|
90
|
+
async function readTail(file, size, bytes = TAIL_BYTES) {
|
|
91
|
+
if (size <= HEAD_BYTES) return { text: '', start: size };
|
|
92
|
+
const length = Math.min(bytes, size - HEAD_BYTES);
|
|
93
|
+
const handle = await fs.promises.open(file, 'r');
|
|
94
|
+
try {
|
|
95
|
+
const { bytesRead, buffer } = await handle.read(Buffer.alloc(length), 0, length, size - length);
|
|
96
|
+
const data = buffer.subarray(0, bytesRead);
|
|
97
|
+
const first = data.indexOf(10) + 1;
|
|
98
|
+
return { text: data.subarray(first).toString('utf8'), start: size - length + first };
|
|
99
|
+
} finally {
|
|
100
|
+
await handle.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
82
104
|
async function readDir(dir) {
|
|
83
105
|
try {
|
|
84
106
|
return await fs.promises.readdir(dir, { withFileTypes: true });
|
|
@@ -127,7 +149,7 @@ export class FileMemo {
|
|
|
127
149
|
const known = this.entries.get(file);
|
|
128
150
|
if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) return known.entry;
|
|
129
151
|
let entry = null;
|
|
130
|
-
try { entry = await parse(); } catch { /* unreadable or not a transcript */ }
|
|
152
|
+
try { entry = await parse(known && known.size <= stat.size ? known.entry : null); } catch { /* unreadable or not a transcript */ }
|
|
131
153
|
this.entries.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, entry });
|
|
132
154
|
return entry;
|
|
133
155
|
}
|
|
@@ -155,22 +177,51 @@ export function claudeConfigDir(env = process.env) {
|
|
|
155
177
|
return (env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')).normalize('NFC');
|
|
156
178
|
}
|
|
157
179
|
|
|
158
|
-
|
|
180
|
+
/** Claude Code's title records in whole lines of `text`, which starts at offset `base`, and where those lines end. */
|
|
181
|
+
function claudeTitles(text, base) {
|
|
182
|
+
const titles = { custom: null, ai: null, end: base };
|
|
183
|
+
let at = base;
|
|
184
|
+
for (const line of text.split('\n').slice(0, -1)) {
|
|
185
|
+
let record = null;
|
|
186
|
+
try { record = JSON.parse(line); } catch { /* a foreign line */ }
|
|
187
|
+
if (record?.type === 'custom-title' && typeof record.customTitle === 'string' && record.customTitle.trim()) titles.custom = { text: record.customTitle, at };
|
|
188
|
+
if (record?.type === 'ai-title' && typeof record.aiTitle === 'string' && record.aiTitle.trim()) titles.ai = { text: record.aiTitle, at };
|
|
189
|
+
at += Buffer.byteLength(line) + 1;
|
|
190
|
+
}
|
|
191
|
+
titles.end = at;
|
|
192
|
+
return titles;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The newest title record by position in the file. Only the start and end are
|
|
197
|
+
* read; a title found earlier still counts while it lies in the unread middle
|
|
198
|
+
* of a file that has only grown.
|
|
199
|
+
*/
|
|
200
|
+
function newestTitle(head, tail, before, key) {
|
|
201
|
+
const kept = before?.titles?.[key];
|
|
202
|
+
const unread = kept && kept.at >= head.end && kept.at < tail.start ? kept : null;
|
|
203
|
+
return [head[key], unread, tail[key]].filter(Boolean).reduce((newest, title) => (title.at > newest.at ? title : newest), { at: -1, text: null });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function claudeEntry(id, text, tail, stat, before) {
|
|
159
207
|
let cwd = null;
|
|
160
208
|
let startedAt = null;
|
|
161
|
-
let
|
|
209
|
+
let prompt = null;
|
|
162
210
|
let seenUser = false;
|
|
163
|
-
|
|
211
|
+
const records = parseLines(text);
|
|
212
|
+
for (const record of records) {
|
|
164
213
|
if (!record || typeof record !== 'object') continue;
|
|
165
214
|
if (cwd === null && typeof record.cwd === 'string') cwd = record.cwd;
|
|
166
215
|
if (startedAt === null && record.timestamp) startedAt = record.timestamp;
|
|
167
|
-
if (record.type === 'custom-title' && typeof record.customTitle === 'string' && record.customTitle.trim()) title = record.customTitle;
|
|
168
216
|
if (record.type !== 'user' || record.isSidechain === true) continue;
|
|
169
217
|
seenUser = true;
|
|
170
|
-
|
|
218
|
+
prompt ??= lineTitle(claudePrompt(record));
|
|
171
219
|
}
|
|
172
220
|
if (cwd === null && !seenUser) return null;
|
|
173
|
-
|
|
221
|
+
const head = claudeTitles(text, 0), end = { ...claudeTitles(tail.text, tail.start), start: tail.start };
|
|
222
|
+
const custom = newestTitle(head, end, before, 'custom'), ai = newestTitle(head, end, before, 'ai');
|
|
223
|
+
const titles = { custom: custom.text === null ? null : custom, ai: ai.text === null ? null : ai };
|
|
224
|
+
return { ...cleanEntry({ id, title: titles.custom?.text ?? titles.ai?.text ?? prompt, cwd, startedAt, updatedAt: stat.mtime }), titles };
|
|
174
225
|
}
|
|
175
226
|
|
|
176
227
|
/** Sessions under `<dir>/projects/<folder>/<id>.jsonl`; sub-agent transcripts sit in sub-folders and are not listed. */
|
|
@@ -188,8 +239,8 @@ export async function listClaudeSessions(dir, memo = new FileMemo()) {
|
|
|
188
239
|
if (!stat) continue;
|
|
189
240
|
seen.add(file);
|
|
190
241
|
const id = item.name.slice(0, -'.jsonl'.length);
|
|
191
|
-
const entry = await memo.entry(file, stat, async () => claudeEntry(id, await readHead(file), stat));
|
|
192
|
-
if (entry) entries.push(entry);
|
|
242
|
+
const entry = await memo.entry(file, stat, async (before) => claudeEntry(id, await readHead(file), await readTail(file, stat.size), stat, before));
|
|
243
|
+
if (entry) entries.push({ ...entry, file });
|
|
193
244
|
}
|
|
194
245
|
}
|
|
195
246
|
memo.prune(root, seen);
|
|
@@ -202,18 +253,6 @@ export function codexHome(env = process.env) {
|
|
|
202
253
|
return env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
203
254
|
}
|
|
204
255
|
|
|
205
|
-
function codexPrompt(record) {
|
|
206
|
-
const payload = record?.payload;
|
|
207
|
-
if (!payload || typeof payload !== 'object') return null;
|
|
208
|
-
if (record.type === 'event_msg') {
|
|
209
|
-
if (payload.type === 'user_message') return promptTitle(payload.message);
|
|
210
|
-
if (payload.type === 'item_completed' && payload.item?.type === 'UserMessage') return promptTitle(contentText(payload.item.content));
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
213
|
-
if (record.type === 'response_item' && payload.type === 'message' && payload.role === 'user') return promptTitle(contentText(payload.content));
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
256
|
function codexEntry(text, stat) {
|
|
218
257
|
const records = parseLines(text);
|
|
219
258
|
const meta = records.find((r) => r?.type === 'session_meta')?.payload;
|
|
@@ -223,7 +262,8 @@ function codexEntry(text, stat) {
|
|
|
223
262
|
if (meta.parent_thread_id || (meta.thread_source && meta.thread_source !== 'user')) return null;
|
|
224
263
|
let title = null;
|
|
225
264
|
for (const record of records) {
|
|
226
|
-
|
|
265
|
+
const message = codexMessage(record);
|
|
266
|
+
title = message?.role === 'user' ? lineTitle(message.text) : null;
|
|
227
267
|
if (title) break;
|
|
228
268
|
}
|
|
229
269
|
return cleanEntry({ id, title, cwd: meta.cwd, startedAt: meta.timestamp ?? records[0]?.timestamp, updatedAt: stat.mtime });
|
|
@@ -238,11 +278,25 @@ async function walk(dir, depth, onFile) {
|
|
|
238
278
|
}
|
|
239
279
|
}
|
|
240
280
|
|
|
281
|
+
async function codexThreadNames(dir, memo) {
|
|
282
|
+
const file = path.join(dir, 'session_index.jsonl');
|
|
283
|
+
const stat = await statFile(file);
|
|
284
|
+
if (!stat) return new Map();
|
|
285
|
+
return (await memo.entry(file, stat, async () => {
|
|
286
|
+
const names = new Map();
|
|
287
|
+
for (const record of parseLines(await fs.promises.readFile(file, 'utf8'))) {
|
|
288
|
+
if (typeof record?.id === 'string' && typeof record.thread_name === 'string' && record.thread_name.trim()) names.set(record.id, record.thread_name);
|
|
289
|
+
}
|
|
290
|
+
return names;
|
|
291
|
+
})) ?? new Map();
|
|
292
|
+
}
|
|
293
|
+
|
|
241
294
|
/** Rollouts under `<dir>/sessions/YYYY/MM/DD/rollout-*.jsonl`, uncompressed or zstd-compressed. Sub-agent threads are skipped. */
|
|
242
295
|
export async function listCodexSessions(dir, memo = new FileMemo()) {
|
|
243
296
|
const root = path.join(dir, 'sessions');
|
|
244
297
|
const entries = [];
|
|
245
298
|
const seen = new Set();
|
|
299
|
+
const names = await codexThreadNames(dir, memo);
|
|
246
300
|
await walk(root, MAX_WALK_DEPTH, async (file, name) => {
|
|
247
301
|
if (!/^rollout-.*\.jsonl(\.zst)?$/.test(name)) return;
|
|
248
302
|
const stat = await statFile(file);
|
|
@@ -252,7 +306,7 @@ export async function listCodexSessions(dir, memo = new FileMemo()) {
|
|
|
252
306
|
const head = await readHead(file);
|
|
253
307
|
return head === null ? null : codexEntry(head, stat);
|
|
254
308
|
});
|
|
255
|
-
if (entry) entries.push(entry);
|
|
309
|
+
if (entry) entries.push({ ...entry, title: cleanEntry({ id: entry.id, title: names.get(entry.id) }).title ?? entry.title, file });
|
|
256
310
|
});
|
|
257
311
|
memo.prune(root, seen);
|
|
258
312
|
return dedupe(entries);
|
|
@@ -299,8 +353,7 @@ function antigravityEntry(id, text, stat) {
|
|
|
299
353
|
let first;
|
|
300
354
|
try { first = JSON.parse((text ?? '').split('\n', 1)[0]); } catch { return null; }
|
|
301
355
|
if (!antigravityUserConversation(first) || typeof first.content !== 'string') return null;
|
|
302
|
-
|
|
303
|
-
return cleanEntry({ id, title: promptTitle(request), startedAt: first.created_at, updatedAt: stat.mtime });
|
|
356
|
+
return cleanEntry({ id, title: lineTitle(transcriptMessage(first)?.text), startedAt: first.created_at, updatedAt: stat.mtime });
|
|
304
357
|
}
|
|
305
358
|
|
|
306
359
|
/** Legacy fallback. Multiple cache paths for one id cannot identify a launch folder. */
|
|
@@ -358,7 +411,7 @@ export async function listAntigravitySessions(dir, memo = new FileMemo(), { plat
|
|
|
358
411
|
const workspaces = summary ? antigravityWorkspaces(summary.workspace_uris, platform) : [];
|
|
359
412
|
const cwd = workspaces.length === 1 ? workspaces[0] : workspaces.length ? null : folders.get(entry.id) ?? null;
|
|
360
413
|
const title = promptTitle(summary?.title) || promptTitle(summary?.preview) || entry.title;
|
|
361
|
-
entries.push({ ...entry, title, cwd, ...(withStatus ? { workspaces: workspaces.length ? workspaces : cwd ? [cwd] : [] } : {}) });
|
|
414
|
+
entries.push({ ...entry, title, cwd, file, ...(withStatus ? { workspaces: workspaces.length ? workspaces : cwd ? [cwd] : [] } : {}) });
|
|
362
415
|
}
|
|
363
416
|
}
|
|
364
417
|
memo.prune(root, seen);
|
|
@@ -393,21 +446,19 @@ async function loadSqlite() {
|
|
|
393
446
|
return sqlite;
|
|
394
447
|
}
|
|
395
448
|
|
|
396
|
-
// A session's title, else
|
|
449
|
+
// A session's title, else the first message its preview shows if that is the user's; the newest message dates it.
|
|
450
|
+
// A sub-agent's session has a parent. No JSON function reads a row larger than $max.
|
|
397
451
|
const DOCKER_SESSIONS_SQL = `
|
|
398
452
|
select s.id, s.title, s.created_at, s.working_dir,
|
|
399
|
-
(select
|
|
400
|
-
|
|
453
|
+
(select case when visible and json_extract(message_json, '$.role') = 'user' then json_extract(message_json, '$.content') end
|
|
454
|
+
from (select position, message_json, ${DOCKER_VISIBLE} as visible from session_items i where i.session_id = s.id and i.item_type = 'message')
|
|
455
|
+
where visible is not 0 order by position limit 1) as first_prompt,
|
|
456
|
+
(select max(case when octet_length(i.message_json) <= $max then json_extract(i.message_json, '$.created_at') end)
|
|
457
|
+
from session_items i where i.session_id = s.id) as updated_at
|
|
401
458
|
from sessions s where s.parent_id is null order by s.created_at desc limit ${MAX_LIMIT}`;
|
|
402
459
|
|
|
403
460
|
function dockerEntry(row) {
|
|
404
|
-
|
|
405
|
-
if (!title && typeof row.first_message === 'string') {
|
|
406
|
-
try {
|
|
407
|
-
const message = JSON.parse(row.first_message);
|
|
408
|
-
if (message?.role === 'user') title = promptTitle(contentText(message.content));
|
|
409
|
-
} catch { /* not a message */ }
|
|
410
|
-
}
|
|
461
|
+
const title = typeof row.title === 'string' && row.title.trim() ? row.title : lineTitle(row.first_prompt);
|
|
411
462
|
return cleanEntry({ id: row.id, title, cwd: row.working_dir, startedAt: row.created_at, updatedAt: row.updated_at ?? row.created_at });
|
|
412
463
|
}
|
|
413
464
|
|
|
@@ -419,7 +470,7 @@ export async function listDockerSessions(dir) {
|
|
|
419
470
|
let db = null;
|
|
420
471
|
try {
|
|
421
472
|
db = new DatabaseSync(file, { readOnly: true });
|
|
422
|
-
return dedupe(db.prepare(DOCKER_SESSIONS_SQL).all().map(dockerEntry).filter(Boolean));
|
|
473
|
+
return dedupe(db.prepare(DOCKER_SESSIONS_SQL).all({ max: HEAD_BYTES }).map(dockerEntry).filter(Boolean));
|
|
423
474
|
} catch (err) {
|
|
424
475
|
throw new HistoryError(`${shortPath(file)} could not be read: ${err.message}`);
|
|
425
476
|
} finally {
|
|
@@ -441,19 +492,22 @@ async function grokBucketCwd(bucket) {
|
|
|
441
492
|
return (await readText(path.join(bucket, '.cwd')))?.trim() || null;
|
|
442
493
|
}
|
|
443
494
|
|
|
495
|
+
/** The first prompt Grok Build shows, its chunks joined as the preview joins them. */
|
|
444
496
|
function grokPrompt(text) {
|
|
445
|
-
|
|
446
|
-
|
|
497
|
+
let turn = null;
|
|
498
|
+
const title = () => (turn && !turn.hidden ? lineTitle(turn.parts.join('')) : null);
|
|
447
499
|
for (const record of parseLines(text)) {
|
|
448
|
-
const
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
parts
|
|
500
|
+
const chunk = grokPromptChunk(record);
|
|
501
|
+
if (turn && (chunk ? chunk.key !== turn.key : GROK_TURN_ACTIVITY.has(record?.params?.update?.sessionUpdate))) {
|
|
502
|
+
if (title()) return title();
|
|
503
|
+
turn = null;
|
|
504
|
+
}
|
|
505
|
+
if (!chunk) continue;
|
|
506
|
+
turn ??= { key: chunk.key, hidden: false, parts: [] };
|
|
507
|
+
if (chunk.hidden) turn.hidden = true;
|
|
508
|
+
else if (chunk.text !== null) turn.parts.push(chunk.text);
|
|
455
509
|
}
|
|
456
|
-
return
|
|
510
|
+
return title();
|
|
457
511
|
}
|
|
458
512
|
|
|
459
513
|
async function grokEntry(folder, summary, stat, bucketCwd) {
|
|
@@ -492,7 +546,7 @@ export async function listGrokSessions(dir, memo = new FileMemo()) {
|
|
|
492
546
|
if (!stat) continue;
|
|
493
547
|
seen.add(file);
|
|
494
548
|
const entry = await memo.entry(file, stat, async () => grokEntry(folder, JSON.parse(await readText(file)), stat, cwd));
|
|
495
|
-
if (entry) entries.push(entry);
|
|
549
|
+
if (entry) entries.push({ ...entry, file: path.join(folder, 'updates.jsonl') });
|
|
496
550
|
}
|
|
497
551
|
}
|
|
498
552
|
memo.prune(root, seen);
|
|
@@ -526,9 +580,11 @@ export class SessionHistory {
|
|
|
526
580
|
* @param {object} [opts.env]
|
|
527
581
|
* @param {number} [opts.ttlMs]
|
|
528
582
|
* @param {object} [opts.readers] session listers by source, replaceable in tests
|
|
583
|
+
* @param {ReturnType<import('./card-names.mjs').createCardNames>|null} [opts.names] the names given to cards, by tool session id
|
|
529
584
|
*/
|
|
530
|
-
constructor({ registry, env = process.env, platform = process.platform, ttlMs = HISTORY_TTL_MS, readers = {} } = {}) {
|
|
585
|
+
constructor({ registry, env = process.env, platform = process.platform, ttlMs = HISTORY_TTL_MS, readers = {}, names = null } = {}) {
|
|
531
586
|
this.registry = registry;
|
|
587
|
+
this.names = names;
|
|
532
588
|
this.env = env;
|
|
533
589
|
this.platform = platform;
|
|
534
590
|
this.ttlMs = ttlMs;
|
|
@@ -550,20 +606,33 @@ export class SessionHistory {
|
|
|
550
606
|
const n = Math.min(MAX_LIMIT, Math.max(1, Math.floor(Number(limit)) || DEFAULT_LIMIT));
|
|
551
607
|
const snapshot = await this.snapshot(provider, account);
|
|
552
608
|
const search = typeof query === 'string' ? query.trim().toLowerCase().slice(0, 500) : '';
|
|
553
|
-
const sessions = snapshot.sessions.
|
|
554
|
-
(
|
|
555
|
-
|
|
609
|
+
const sessions = snapshot.sessions.map(({ file, titles, ...entry }) => ({ ...entry, name: this.names?.get(provider.id, account.id, entry.id) ?? null }))
|
|
610
|
+
.filter((entry) =>
|
|
611
|
+
(!cwd || (entry.workspaces ?? [entry.cwd]).some((folder) => sameHistoryFolder(folder, cwd, this.platform)))
|
|
612
|
+
&& (!search || [entry.id, entry.name, entry.title, ...(entry.workspaces ?? [entry.cwd])].join('\n').toLowerCase().includes(search)));
|
|
556
613
|
return { ...snapshot, total: sessions.length, sessions: sessions.slice(0, n) };
|
|
557
614
|
}
|
|
558
615
|
|
|
559
616
|
async detail(provider, account, id, cursor) {
|
|
560
|
-
|
|
561
|
-
if (!
|
|
617
|
+
const source = provider.history;
|
|
618
|
+
if (!DETAIL_SOURCES.has(source)) throw historyError(400, 'history_detail_unsupported', 'This tool has no conversation preview.');
|
|
619
|
+
if (typeof id !== 'string' || cleanEntry({ id })?.id !== id || (source === 'antigravity' && !ANTIGRAVITY_ID.test(id))) {
|
|
620
|
+
throw historyError(400, 'bad_history_id', 'Invalid conversation id.');
|
|
621
|
+
}
|
|
562
622
|
// Revalidate membership, even if the client still has an older list open.
|
|
563
623
|
const snapshot = await this._read(provider, account);
|
|
564
|
-
|
|
624
|
+
const entry = snapshot.sessions.find((s) => s.id === id);
|
|
625
|
+
if (!entry) throw historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
|
|
565
626
|
const env = { ...this.env, ...provider.env, ...account.env };
|
|
566
|
-
|
|
627
|
+
const dir = SessionHistory.sourceDir(source, env, this.platform);
|
|
628
|
+
if (source === 'docker') return readDockerConversation({ dir, id, cursor, loadSqlite });
|
|
629
|
+
if (source === 'antigravity') return readAntigravityTranscript(dir, id, cursor);
|
|
630
|
+
if (typeof entry.file !== 'string') throw historyError(404, 'history_unavailable', 'This conversation transcript is unavailable.');
|
|
631
|
+
const root = path.join(dir, TRANSCRIPT_ROOTS[source]);
|
|
632
|
+
// Codex may have compressed or decompressed the rollout since it was listed.
|
|
633
|
+
const other = entry.file.endsWith('.zst') ? entry.file.slice(0, -4) : `${entry.file}.zst`;
|
|
634
|
+
const file = await checkedFile(root, entry.file).catch((err) => (source === 'codex' ? checkedFile(root, other) : Promise.reject(err)));
|
|
635
|
+
return readJsonlPage({ source, id, file, cursor, ...JSONL_READERS[source] });
|
|
567
636
|
}
|
|
568
637
|
|
|
569
638
|
snapshot(provider, account) {
|
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { buildSpawnSpec, runSpec, killWindowsTree } from './command-resolver.mjs';
|
|
7
7
|
import { compareVersions, probeVersion } from './versions.mjs';
|
|
8
|
+
import { commandMtime } from './install-channels.mjs';
|
|
8
9
|
|
|
9
10
|
export const REPORT_COMMAND = 'agent-guild-report --hook';
|
|
10
11
|
export const PLUGIN_NAME = 'agent-guild';
|
|
@@ -336,8 +337,7 @@ export class SessionHooks {
|
|
|
336
337
|
_probe(provider) {
|
|
337
338
|
const resolved = this.registry.resolve(provider);
|
|
338
339
|
if (!resolved) return Promise.resolve(null);
|
|
339
|
-
|
|
340
|
-
try { mtime = fs.statSync(resolved).mtimeMs; } catch { /* probe anyway */ }
|
|
340
|
+
const mtime = commandMtime(resolved, this.registry.platform);
|
|
341
341
|
const cached = this.probes.get(provider.id);
|
|
342
342
|
// A probe still running is shared. A finished one is reused while the binary is the same, for good when it found the
|
|
343
343
|
// hooks and for the retry interval when it did not, unless it asked to be rechecked by the next session (Docker Agent).
|