@oddessentials/agent-guild 0.11.0 → 0.12.1
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 +14 -16
- package/bin/agent-guild-report.mjs +37 -17
- package/config/providers.default.json +15 -15
- package/package.json +1 -1
- package/src/manager/config.mjs +0 -1
- package/src/manager/install-channels.mjs +7 -0
- package/src/manager/main.mjs +3 -4
- package/src/manager/news.mjs +1 -1
- package/src/manager/providers.mjs +9 -11
- package/src/manager/report-shims.mjs +4 -4
- package/src/manager/server.mjs +1 -2
- package/src/manager/session-history.mjs +32 -74
- package/src/manager/session-hooks.mjs +79 -78
- package/src/manager/session-manager.mjs +3 -33
- package/src/manager/session.mjs +9 -83
- package/src/manager/usage.mjs +3 -258
- package/src/report/hooks.mjs +27 -48
- package/web/app.js +49 -11
- package/web/styles.css +8 -0
- package/examples/gemini-settings.json +0 -61
|
@@ -5,8 +5,8 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import { buildSpawnSpec, runSpec, killWindowsTree } from './command-resolver.mjs';
|
|
6
6
|
|
|
7
7
|
export const REPORT_COMMAND = 'agent-guild-report --hook';
|
|
8
|
-
export const
|
|
9
|
-
const MANIFEST_DESCRIPTION = 'Reports
|
|
8
|
+
export const PLUGIN_NAME = 'agent-guild';
|
|
9
|
+
const MANIFEST_DESCRIPTION = 'Reports to the Agent Guild session it runs in.';
|
|
10
10
|
const PROBE_TIMEOUT_MS = 20000;
|
|
11
11
|
const PROBE_RETRY_MS = 5 * 60 * 1000;
|
|
12
12
|
|
|
@@ -28,35 +28,32 @@ const claudeHooks = () => Object.fromEntries(CLAUDE_EVENTS.map((event) => [event
|
|
|
28
28
|
}]]));
|
|
29
29
|
const GROK_EVENTS = ['SessionStart', 'SubagentStart', 'SubagentStop', 'StopCancelled', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop'];
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
// Antigravity CLI loads an installed plugin into every session, also those started outside Agent Guild, and runs
|
|
32
|
+
// its hooks in the plugin's folder, on Windows through cmd /C, which cannot take a quoted path. So the hook is a
|
|
33
|
+
// script in that folder: it reports only inside an Agent Guild Antigravity session, and always prints the JSON object
|
|
34
|
+
// Antigravity reads.
|
|
35
|
+
function antigravityFiles(manifest, platform) {
|
|
36
|
+
const script = platform === 'win32'
|
|
37
|
+
? ['@echo off', `if "%AGENT_GUILD_REPORTING%"=="antigravity" call ${REPORT_COMMAND} --event %1`, 'echo {}', ''].join('\r\n')
|
|
38
|
+
: ['#!/bin/sh', `if [ "$AGENT_GUILD_REPORTING" = antigravity ] && command -v agent-guild-report >/dev/null 2>&1; then ${REPORT_COMMAND} --event "$1"; else cat >/dev/null; fi`, "echo '{}'", ''].join('\n');
|
|
39
|
+
const name = platform === 'win32' ? 'agent-guild-hook.cmd' : 'agent-guild-hook.sh';
|
|
40
|
+
const command = platform === 'win32' ? `.\\${name} PreInvocation` : `sh ${name} PreInvocation`;
|
|
41
|
+
return {
|
|
42
|
+
'plugin.json': `${JSON.stringify(manifest, null, 2)}\n`,
|
|
43
|
+
'hooks.json': `${JSON.stringify({ [PLUGIN_NAME]: { PreInvocation: [{ type: 'command', command, timeout: 10 }] } }, null, 2)}\n`,
|
|
44
|
+
[name]: script,
|
|
45
|
+
};
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
export function bundleFiles(version, {
|
|
48
|
+
export function bundleFiles(version, { platform = process.platform } = {}) {
|
|
39
49
|
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
40
|
-
const manifest = { name:
|
|
41
|
-
const gemini = (name, matcher) => ({ ...(matcher ? { matcher } : {}), hooks: [handler({ name: `Agent Guild ${name}`, command: geminiCommand(shimDir, platform) })] });
|
|
50
|
+
const manifest = { name: PLUGIN_NAME, version, description: MANIFEST_DESCRIPTION };
|
|
42
51
|
return {
|
|
43
52
|
claude: {
|
|
44
53
|
'.claude-plugin/plugin.json': json(manifest),
|
|
45
54
|
'hooks/hooks.json': json({ hooks: claudeHooks() }),
|
|
46
55
|
},
|
|
47
|
-
|
|
48
|
-
'gemini-extension.json': json(manifest),
|
|
49
|
-
'hooks/hooks.json': json({
|
|
50
|
-
hooks: {
|
|
51
|
-
SessionStart: [gemini('session start')],
|
|
52
|
-
BeforeTool: [gemini('agent start', 'invoke_agent'), gemini('shell start', 'run_shell_command')],
|
|
53
|
-
AfterTool: [gemini('agent stop', 'invoke_agent'), gemini('shell stop', 'run_shell_command')],
|
|
54
|
-
BeforeAgent: [gemini('turn start')],
|
|
55
|
-
AfterAgent: [gemini('turn end')],
|
|
56
|
-
BeforeModel: [gemini('model')],
|
|
57
|
-
},
|
|
58
|
-
}),
|
|
59
|
-
},
|
|
56
|
+
antigravity: antigravityFiles({ name: PLUGIN_NAME, description: MANIFEST_DESCRIPTION }, platform),
|
|
60
57
|
grok: {
|
|
61
58
|
'.grok-plugin/plugin.json': json(manifest),
|
|
62
59
|
'hooks/hooks.json': json({
|
|
@@ -188,50 +185,51 @@ export function helpLists(text, flag) {
|
|
|
188
185
|
return new RegExp(`^\\s+(?:-\\w, )?${flag.replace(/[-]/g, '\\-')}\\b`, 'm').test(text);
|
|
189
186
|
}
|
|
190
187
|
|
|
191
|
-
|
|
192
|
-
return env.GEMINI_CLI_HOME || os.homedir();
|
|
193
|
-
}
|
|
188
|
+
const geminiHome = (env, platform) => path.join((platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir(), '.gemini');
|
|
194
189
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
} catch {
|
|
199
|
-
return null;
|
|
200
|
-
}
|
|
190
|
+
/** Where `agy plugin install` copies the plugin and where Antigravity CLI loads it from. */
|
|
191
|
+
export function antigravityPluginDir(env = {}, platform = process.platform) {
|
|
192
|
+
return path.join(geminiHome(env, platform), 'config', 'plugins', PLUGIN_NAME);
|
|
201
193
|
}
|
|
202
194
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return
|
|
195
|
+
/** The shared Antigravity settings, where `agy plugin disable` turns a plugin off. */
|
|
196
|
+
export function antigravityConfigFile(env = {}, platform = process.platform) {
|
|
197
|
+
return path.join(geminiHome(env, platform), 'config', 'config.json');
|
|
206
198
|
}
|
|
207
199
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (record.type !== 'link' || typeof record.source !== 'string') return false;
|
|
200
|
+
/** True unless the settings turn the plugin off or cannot be read; no settings file means every plugin is on. */
|
|
201
|
+
export function antigravityPluginEnabled(configFile) {
|
|
202
|
+
let config;
|
|
212
203
|
try {
|
|
213
|
-
|
|
204
|
+
config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
214
205
|
} catch (err) {
|
|
215
206
|
return err.code === 'ENOENT';
|
|
216
207
|
}
|
|
208
|
+
if (!config || typeof config !== 'object') return false;
|
|
209
|
+
return config.plugins?.[PLUGIN_NAME]?.enabled !== false;
|
|
217
210
|
}
|
|
218
211
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
212
|
+
function readOr(file, fallback) {
|
|
213
|
+
try { return fs.readFileSync(file, 'utf8'); } catch { return fallback; }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** "current" when the installed copy matches the bundle, "stale" when it is an older one of ours, "other" or null. */
|
|
217
|
+
export function antigravityInstalled(pluginDir, bundle) {
|
|
218
|
+
const manifest = readOr(path.join(pluginDir, 'plugin.json'), null);
|
|
219
|
+
if (manifest === null) return null;
|
|
220
|
+
let description = null;
|
|
221
|
+
try { description = JSON.parse(manifest)?.description; } catch { /* not ours */ }
|
|
222
|
+
if (description !== MANIFEST_DESCRIPTION) return 'other';
|
|
223
|
+
let names = [];
|
|
224
|
+
try { names = fs.readdirSync(bundle); } catch { return 'stale'; }
|
|
225
|
+
const same = names.every((name) => readOr(path.join(pluginDir, name), null) === readOr(path.join(bundle, name), ''));
|
|
226
|
+
return same ? 'current' : 'stale';
|
|
229
227
|
}
|
|
230
228
|
|
|
231
229
|
const pending = (tool, when) => ({ state: 'pending', reason: `Agent Guild added its reporting hooks to this ${tool} session. They report once ${tool} ${when}.` });
|
|
232
230
|
|
|
233
231
|
export class SessionHooks {
|
|
234
|
-
constructor({ registry, dir, version,
|
|
232
|
+
constructor({ registry, dir, version, probeTimeoutMs = PROBE_TIMEOUT_MS, probeRetryMs = PROBE_RETRY_MS }) {
|
|
235
233
|
this.registry = registry;
|
|
236
234
|
this.probeTimeoutMs = probeTimeoutMs;
|
|
237
235
|
this.probeRetryMs = probeRetryMs;
|
|
@@ -240,7 +238,7 @@ export class SessionHooks {
|
|
|
240
238
|
this.probes = new Map();
|
|
241
239
|
if (dir) {
|
|
242
240
|
try {
|
|
243
|
-
this.bundles = writeBundles(dir, version, {
|
|
241
|
+
this.bundles = writeBundles(dir, version, { platform: registry.platform });
|
|
244
242
|
} catch (err) {
|
|
245
243
|
console.warn(`[reporting] could not write the reporting hooks to ${dir}: ${err.message}`);
|
|
246
244
|
}
|
|
@@ -278,15 +276,15 @@ export class SessionHooks {
|
|
|
278
276
|
return entry.promise;
|
|
279
277
|
}
|
|
280
278
|
|
|
281
|
-
async launch(provider
|
|
279
|
+
async launch(provider) {
|
|
282
280
|
const mode = provider.reporting;
|
|
283
281
|
if (!mode) return { args: [], reporting: null };
|
|
284
282
|
const tool = provider.tool;
|
|
285
283
|
if (!this.bundles) {
|
|
286
284
|
return { args: [], reporting: { state: 'unavailable', reason: `Agent Guild could not write its reporting hooks, so ${tool} cannot report agents.` } };
|
|
287
285
|
}
|
|
288
|
-
if (mode === '
|
|
289
|
-
if (this.enabled(provider
|
|
286
|
+
if (mode === 'antigravity') {
|
|
287
|
+
if (this.enabled(provider)) return { args: [], reporting: pending(tool, 'runs its first prompt') };
|
|
290
288
|
return { args: [], reporting: { state: 'setup_required', reason: `Agent reporting is off for ${tool}. Turn it on from the ${tool} card; it applies to new sessions.` } };
|
|
291
289
|
}
|
|
292
290
|
const probe = await this._probe(provider);
|
|
@@ -308,42 +306,45 @@ export class SessionHooks {
|
|
|
308
306
|
};
|
|
309
307
|
}
|
|
310
308
|
|
|
311
|
-
enabled(provider
|
|
312
|
-
if (provider.reporting !== '
|
|
313
|
-
return
|
|
309
|
+
enabled(provider) {
|
|
310
|
+
if (provider.reporting !== 'antigravity' || !this.bundles) return null;
|
|
311
|
+
return this._installed(provider) === 'current' && this._pluginEnabled(provider);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_installed(provider) {
|
|
315
|
+
return antigravityInstalled(antigravityPluginDir({ ...this.registry.env, ...provider.env }, this.registry.platform), this.bundles.antigravity);
|
|
314
316
|
}
|
|
315
317
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
_pluginEnabled(provider) {
|
|
319
|
+
return antigravityPluginEnabled(antigravityConfigFile({ ...this.registry.env, ...provider.env }, this.registry.platform));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async setEnabled(provider, enabled) {
|
|
323
|
+
if (provider.reporting !== 'antigravity') throw Object.assign(new Error(`${provider.tool} needs no setup for agent reporting`), { status: 400, code: 'not_applicable' });
|
|
318
324
|
if (!this.bundles) throw Object.assign(new Error('Agent Guild could not write its reporting hooks'), { status: 500, code: 'reporting_unavailable' });
|
|
319
325
|
const resolved = this.registry.resolve(provider);
|
|
320
326
|
if (!resolved) throw Object.assign(new Error(`${provider.tool} is not installed`), { status: 409, code: 'provider_unavailable' });
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
throw Object.assign(new Error(`${provider.tool} already has another extension named "${EXTENSION_NAME}". Remove it with "${provider.command} extensions uninstall ${EXTENSION_NAME}" to turn on agent reporting.`), { status: 409, code: 'extension_conflict' });
|
|
327
|
+
const installed = this._installed(provider);
|
|
328
|
+
if (enabled ? installed === 'current' && this._pluginEnabled(provider) : !installed) return enabled;
|
|
329
|
+
if (installed === 'other') {
|
|
330
|
+
if (!enabled) return false;
|
|
331
|
+
throw Object.assign(new Error(`${provider.tool} already has another plugin named "${PLUGIN_NAME}". Remove it with "${provider.command} plugin uninstall ${PLUGIN_NAME}" to turn on agent reporting.`), { status: 409, code: 'plugin_conflict' });
|
|
327
332
|
}
|
|
333
|
+
const env = { ...this.registry.env, ...provider.env };
|
|
328
334
|
const run = async (args, what) => {
|
|
329
335
|
try {
|
|
330
|
-
|
|
331
|
-
await runSpec(buildSpawnSpec(resolved, args, commandEnv, this.registry.platform), { env: commandEnv, timeoutMs: 60000, cwd: this.dir });
|
|
336
|
+
await runSpec(buildSpawnSpec(resolved, args, env, this.registry.platform), { env, timeoutMs: 60000, cwd: this.dir });
|
|
332
337
|
} catch (err) {
|
|
333
338
|
const detail = `${err.stderr || ''}\n${err.stdout || ''}`.trim().split('\n').filter(Boolean).pop() || err.message;
|
|
334
|
-
throw Object.assign(new Error(`${provider.tool} could not ${what} the Agent Guild
|
|
339
|
+
throw Object.assign(new Error(`${provider.tool} could not ${what} the Agent Guild plugin: ${detail}`), { status: 502, code: 'reporting_setup_failed' });
|
|
335
340
|
}
|
|
336
341
|
};
|
|
337
|
-
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
await run(['extensions', 'uninstall', EXTENSION_NAME], 'remove');
|
|
342
|
-
}
|
|
343
|
-
if (enabled) await run(['extensions', 'link', this.bundles.gemini, '--consent'], 'link');
|
|
344
|
-
const now = this.enabled(provider, account);
|
|
342
|
+
if (installed && (!enabled || installed === 'stale')) await run(['plugin', 'uninstall', PLUGIN_NAME], 'remove');
|
|
343
|
+
if (enabled && installed !== 'current') await run(['plugin', 'install', this.bundles.antigravity], 'install');
|
|
344
|
+
if (enabled && !this._pluginEnabled(provider)) await run(['plugin', 'enable', PLUGIN_NAME], 'turn on');
|
|
345
|
+
const now = this.enabled(provider);
|
|
345
346
|
if (now !== enabled) {
|
|
346
|
-
throw Object.assign(new Error(`${provider.tool} reported success, but the Agent Guild
|
|
347
|
+
throw Object.assign(new Error(`${provider.tool} reported success, but the Agent Guild plugin is ${now ? 'still on' : 'not on'}`), { status: 502, code: 'reporting_setup_failed' });
|
|
347
348
|
}
|
|
348
349
|
return now;
|
|
349
350
|
}
|
|
@@ -54,7 +54,7 @@ export class SessionManager extends EventEmitter {
|
|
|
54
54
|
* @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
|
|
55
55
|
* @param {import('./github.mjs').GitHub|null} [opts.github]
|
|
56
56
|
*/
|
|
57
|
-
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null
|
|
57
|
+
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null }) {
|
|
58
58
|
super();
|
|
59
59
|
this.registry = registry;
|
|
60
60
|
this.baseEnv = baseEnv;
|
|
@@ -64,7 +64,6 @@ export class SessionManager extends EventEmitter {
|
|
|
64
64
|
this.selfUpdate = selfUpdate;
|
|
65
65
|
this.github = github;
|
|
66
66
|
this.sessionHooks = sessionHooks;
|
|
67
|
-
this.reportTokenDir = reportTokenDir;
|
|
68
67
|
this.sessions = new Map();
|
|
69
68
|
/** Removed sessions whose process has not exited yet. */
|
|
70
69
|
this.exiting = new Set();
|
|
@@ -104,7 +103,7 @@ export class SessionManager extends EventEmitter {
|
|
|
104
103
|
const resumeId = cleanResumeId(resume);
|
|
105
104
|
const workDir = this.resolveCwd(cwd);
|
|
106
105
|
const signIn = this.registry.account(provider, account);
|
|
107
|
-
const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider
|
|
106
|
+
const hooks = this.sessionHooks ? await this.sessionHooks.launch(provider) : { args: [], reporting: null };
|
|
108
107
|
const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args);
|
|
109
108
|
this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
|
|
110
109
|
const sessionName = cleanName(name) || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null);
|
|
@@ -236,7 +235,6 @@ export class SessionManager extends EventEmitter {
|
|
|
236
235
|
}
|
|
237
236
|
const id = newId();
|
|
238
237
|
const reportToken = crypto.randomBytes(16).toString('hex');
|
|
239
|
-
const reportFile = this._writeReportToken(id, reportToken);
|
|
240
238
|
|
|
241
239
|
// The tool's hooks run `agent-guild-report` by name, so the launchers
|
|
242
240
|
// go first on PATH, after any provider PATH override.
|
|
@@ -245,9 +243,9 @@ export class SessionManager extends EventEmitter {
|
|
|
245
243
|
COLORTERM: 'truecolor',
|
|
246
244
|
AGENT_GUILD_SESSION_ID: id,
|
|
247
245
|
AGENT_GUILD_PROVIDER: provider.id,
|
|
246
|
+
AGENT_GUILD_REPORTING: provider.reporting || '',
|
|
248
247
|
AGENT_GUILD_URL: this.getApiUrl(),
|
|
249
248
|
AGENT_GUILD_REPORT_TOKEN: reportToken,
|
|
250
|
-
AGENT_GUILD_REPORT_FILE: reportFile,
|
|
251
249
|
AGENT_GUILD_NODE: process.execPath,
|
|
252
250
|
}]), this.shimDir);
|
|
253
251
|
// The tool runs in its own terminal, not in the terminal or multiplexer
|
|
@@ -278,10 +276,8 @@ export class SessionManager extends EventEmitter {
|
|
|
278
276
|
reporting,
|
|
279
277
|
});
|
|
280
278
|
} catch (err) {
|
|
281
|
-
this._removeReportToken(reportFile);
|
|
282
279
|
throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
|
|
283
280
|
}
|
|
284
|
-
session.exited.then(() => this._removeReportToken(reportFile));
|
|
285
281
|
|
|
286
282
|
session.on('changed', () => {
|
|
287
283
|
if (this.sessions.has(id)) this.emit('event', { type: 'session.updated', session: session.toJSON() });
|
|
@@ -303,32 +299,6 @@ export class SessionManager extends EventEmitter {
|
|
|
303
299
|
return session;
|
|
304
300
|
}
|
|
305
301
|
|
|
306
|
-
// Only once this manager owns the port, so a second one that fails to start leaves the running one's files.
|
|
307
|
-
sweepReportTokens() {
|
|
308
|
-
if (!this.reportTokenDir) return;
|
|
309
|
-
const live = new Set(this.sessions.keys());
|
|
310
|
-
let names = [];
|
|
311
|
-
try { names = fs.readdirSync(this.reportTokenDir); } catch { return; }
|
|
312
|
-
for (const name of names) if (!live.has(name)) fs.rm(path.join(this.reportTokenDir, name), { force: true }, () => {});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
_writeReportToken(id, token) {
|
|
316
|
-
if (!this.reportTokenDir) return null;
|
|
317
|
-
try {
|
|
318
|
-
fs.mkdirSync(this.reportTokenDir, { recursive: true, mode: 0o700 });
|
|
319
|
-
const file = path.join(this.reportTokenDir, id);
|
|
320
|
-
fs.writeFileSync(file, token, { mode: 0o600, flag: 'wx' });
|
|
321
|
-
return file;
|
|
322
|
-
} catch (err) {
|
|
323
|
-
console.warn(`[manager] could not write the report token file for session ${id}: ${err.message}`);
|
|
324
|
-
return null;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
_removeReportToken(file) {
|
|
329
|
-
if (file) fs.rm(file, { force: true }, () => {});
|
|
330
|
-
}
|
|
331
|
-
|
|
332
302
|
/** Remove a session from the list, ending its process if still running. */
|
|
333
303
|
remove(id) {
|
|
334
304
|
const session = this.get(id);
|
package/src/manager/session.mjs
CHANGED
|
@@ -22,18 +22,8 @@ const MAX_TOOL_SESSION_ID = 200;
|
|
|
22
22
|
const REPORTING_STATES = new Set(['pending', 'active', 'unavailable', 'setup_required', 'unsupported']);
|
|
23
23
|
const SHELL_EVENTS = new Set(['start', 'waiting', 'asked', 'background', 'end', 'running', 'reset']);
|
|
24
24
|
const MAX_SHELLS = 256;
|
|
25
|
-
const MAX_SHELL_PIDS = 16;
|
|
26
25
|
const MAX_ENDED_TASKS = 256;
|
|
27
26
|
|
|
28
|
-
function processExists(pid) {
|
|
29
|
-
try {
|
|
30
|
-
process.kill(pid, 0);
|
|
31
|
-
return true;
|
|
32
|
-
} catch (err) {
|
|
33
|
-
return err.code === 'EPERM';
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
27
|
/**
|
|
38
28
|
* Colours reported to programs that query them (OSC 10/11/12), matching the
|
|
39
29
|
* web page's terminal theme. Tools use these to choose light or dark output.
|
|
@@ -99,11 +89,9 @@ export class Session extends EventEmitter {
|
|
|
99
89
|
this.reporting = REPORTING_STATES.has(opts.reporting?.state) ? { state: opts.reporting.state, reason: opts.reporting.reason ?? null } : null;
|
|
100
90
|
this._reportingTimer = null;
|
|
101
91
|
this.shellDisplayDelayMs = opts.shellDisplayDelayMs ?? 600;
|
|
102
|
-
this.shellPidCheckMs = opts.shellPidCheckMs ?? 2000;
|
|
103
92
|
this.shells = new Map();
|
|
104
93
|
this._shellSeq = 0;
|
|
105
94
|
this._endedTasks = new Set();
|
|
106
|
-
this._pidTimer = null;
|
|
107
95
|
this.createdAt = new Date().toISOString();
|
|
108
96
|
this.exitedAt = null;
|
|
109
97
|
this.status = 'running';
|
|
@@ -381,11 +369,6 @@ export class Session extends EventEmitter {
|
|
|
381
369
|
if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
|
|
382
370
|
if (source === 'api') this._reportingHeard();
|
|
383
371
|
if (report.finishForeground === true && report.agentId === undefined) {
|
|
384
|
-
// The tool is between turns, so no foreground agent can still be
|
|
385
|
-
// running; one that is never got its end event.
|
|
386
|
-
for (const agent of [...this.agents.values()]) {
|
|
387
|
-
if (agent.foreground && agent.status === 'working') this.reportAgent({ agentId: agent.id, status: 'done' }, source);
|
|
388
|
-
}
|
|
389
372
|
this._endForegroundShells(null);
|
|
390
373
|
return null;
|
|
391
374
|
}
|
|
@@ -402,11 +385,6 @@ export class Session extends EventEmitter {
|
|
|
402
385
|
}
|
|
403
386
|
const now = new Date().toISOString();
|
|
404
387
|
const existing = this.agents.get(id);
|
|
405
|
-
if (!existing && status === 'done' && report.foreground === true) {
|
|
406
|
-
// Gemini CLI's invoke_agent is paired by its input, which a BeforeTool hook may rewrite before AfterTool.
|
|
407
|
-
const working = [...this.agents.values()].filter((agent) => agent.foreground && agent.status === 'working');
|
|
408
|
-
return working.length === 1 ? this.reportAgent({ agentId: working[0].id, status: 'done' }, source) : null;
|
|
409
|
-
}
|
|
410
388
|
if (status === 'done') this._endForegroundShells(id);
|
|
411
389
|
// A first report that already says done would only flash an icon:
|
|
412
390
|
// Claude Code's internal helpers (prompt suggestions, side questions)
|
|
@@ -424,9 +402,6 @@ export class Session extends EventEmitter {
|
|
|
424
402
|
kind: String(report.kind ?? existing?.kind ?? 'agent').slice(0, 40),
|
|
425
403
|
status,
|
|
426
404
|
detail: report.detail === undefined ? existing?.detail ?? '' : String(report.detail).slice(0, 200),
|
|
427
|
-
// The parent waits for a foreground agent, so a model reported while
|
|
428
|
-
// it works belongs to the agent, not to the session.
|
|
429
|
-
foreground: report.foreground === undefined ? existing?.foreground ?? false : report.foreground === true,
|
|
430
405
|
startedAt: existing?.startedAt ?? now,
|
|
431
406
|
updatedAt: now,
|
|
432
407
|
source,
|
|
@@ -488,7 +463,6 @@ export class Session extends EventEmitter {
|
|
|
488
463
|
if (!SHELL_EVENTS.has(report.shell)) throw badRequest(`shell must be one of ${[...SHELL_EVENTS].join(', ')}`);
|
|
489
464
|
const id = (value) => (typeof value === 'string' && value ? value.slice(0, 128) : null);
|
|
490
465
|
const key = id(report.key);
|
|
491
|
-
const bucket = !key && typeof report.bucket === 'string' && /^[a-f0-9]{1,64}$/.test(report.bucket) ? report.bucket : null;
|
|
492
466
|
const task = id(report.task);
|
|
493
467
|
const match = typeof report.match === 'string' && /^[a-f0-9]{32}$/.test(report.match) ? report.match : null;
|
|
494
468
|
const agentId = id(report.agentId);
|
|
@@ -525,34 +499,23 @@ export class Session extends EventEmitter {
|
|
|
525
499
|
}
|
|
526
500
|
|
|
527
501
|
if (report.shell === 'end') {
|
|
528
|
-
if (!key && !
|
|
502
|
+
if (!key && !task) throw badRequest('a shell end needs a key or a task');
|
|
529
503
|
if (task) this._rememberEnded(task);
|
|
530
|
-
const shell = (key && this._shellBy('key', key)) || (task && this._shellBy('task', task))
|
|
504
|
+
const shell = (key && this._shellBy('key', key)) || (task && this._shellBy('task', task));
|
|
531
505
|
if (shell) this._endShell(shell);
|
|
532
506
|
return null;
|
|
533
507
|
}
|
|
534
508
|
|
|
535
|
-
if (!key
|
|
509
|
+
if (!key) throw badRequest('a shell report needs a key');
|
|
536
510
|
if (report.shell === 'start') {
|
|
537
|
-
if (!
|
|
511
|
+
if (!this._shellBy('key', key)) this._addShell({ key, match, agentId, persist: report.persist === true });
|
|
538
512
|
return null;
|
|
539
513
|
}
|
|
540
514
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const shell = key ? this._shellBy('key', key) : this._shellByBucket(bucket);
|
|
515
|
+
if (!task) throw badRequest('a background report needs a task');
|
|
516
|
+
const shell = this._shellBy('key', key);
|
|
544
517
|
if (!shell) return null;
|
|
545
|
-
|
|
546
|
-
Object.assign(shell, { task, endsWithAgent: report.endsWithAgent === true });
|
|
547
|
-
} else {
|
|
548
|
-
// Gemini CLI names a pid even for a command that ended at once.
|
|
549
|
-
shell.pids = pids.filter(processExists);
|
|
550
|
-
if (shell.pids.length === 0) {
|
|
551
|
-
this._endShell(shell);
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
this._checkPids();
|
|
555
|
-
}
|
|
518
|
+
Object.assign(shell, { task, endsWithAgent: report.endsWithAgent === true });
|
|
556
519
|
if (shell.waiting) {
|
|
557
520
|
shell.waiting = false;
|
|
558
521
|
this._showAfterDelay(shell);
|
|
@@ -566,7 +529,7 @@ export class Session extends EventEmitter {
|
|
|
566
529
|
return;
|
|
567
530
|
}
|
|
568
531
|
const shell = {
|
|
569
|
-
id: `shell-${++this._shellSeq}`, key: null,
|
|
532
|
+
id: `shell-${++this._shellSeq}`, key: null, match: null, agentId: null, persist: false, task: null,
|
|
570
533
|
endsWithAgent: false, waiting: false, asked: false, visible: false, timer: null, ...fields,
|
|
571
534
|
};
|
|
572
535
|
this.shells.set(shell.id, shell);
|
|
@@ -587,7 +550,7 @@ export class Session extends EventEmitter {
|
|
|
587
550
|
}
|
|
588
551
|
|
|
589
552
|
_foreground(shell) {
|
|
590
|
-
return shell.task === null
|
|
553
|
+
return shell.task === null;
|
|
591
554
|
}
|
|
592
555
|
|
|
593
556
|
_shellBy(field, value) {
|
|
@@ -595,30 +558,6 @@ export class Session extends EventEmitter {
|
|
|
595
558
|
return null;
|
|
596
559
|
}
|
|
597
560
|
|
|
598
|
-
// Gemini CLI's calls are paired by their input, which a BeforeTool hook may rewrite before AfterTool.
|
|
599
|
-
_shellByBucket(bucket) {
|
|
600
|
-
const open = [...this.shells.values()].filter((shell) => shell.bucket !== null && this._foreground(shell));
|
|
601
|
-
return open.find((shell) => shell.bucket === bucket) ?? (open.length === 1 ? open[0] : null);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
_checkPids() {
|
|
605
|
-
if (this._pidTimer) return;
|
|
606
|
-
this._pidTimer = setInterval(() => {
|
|
607
|
-
let watching = false;
|
|
608
|
-
for (const shell of [...this.shells.values()]) {
|
|
609
|
-
if (!shell.pids) continue;
|
|
610
|
-
shell.pids = shell.pids.filter(processExists);
|
|
611
|
-
if (shell.pids.length) watching = true;
|
|
612
|
-
else this._endShell(shell);
|
|
613
|
-
}
|
|
614
|
-
if (!watching) {
|
|
615
|
-
clearInterval(this._pidTimer);
|
|
616
|
-
this._pidTimer = null;
|
|
617
|
-
}
|
|
618
|
-
}, this.shellPidCheckMs);
|
|
619
|
-
this._pidTimer.unref?.();
|
|
620
|
-
}
|
|
621
|
-
|
|
622
561
|
// A turn's foreground commands end with it; a background command, or one Codex CLI keeps running, does not.
|
|
623
562
|
_endForegroundShells(agentId) {
|
|
624
563
|
for (const shell of [...this.shells.values()]) {
|
|
@@ -642,8 +581,6 @@ export class Session extends EventEmitter {
|
|
|
642
581
|
|
|
643
582
|
_clearShells() {
|
|
644
583
|
for (const shell of this.shells.values()) clearTimeout(shell.timer);
|
|
645
|
-
clearInterval(this._pidTimer);
|
|
646
|
-
this._pidTimer = null;
|
|
647
584
|
this.shells.clear();
|
|
648
585
|
}
|
|
649
586
|
|
|
@@ -668,10 +605,6 @@ export class Session extends EventEmitter {
|
|
|
668
605
|
const name = String(report.model ?? '').trim().slice(0, 120);
|
|
669
606
|
if (!name) throw badRequest('model is required');
|
|
670
607
|
const displayName = report.displayName === undefined || report.displayName === null ? null : String(report.displayName).trim().slice(0, 80) || null;
|
|
671
|
-
// Gemini CLI fires BeforeModel for a sub-agent's requests too, with the
|
|
672
|
-
// sub-agent's model and nothing to tell them apart. While a foreground
|
|
673
|
-
// agent works its parent makes no request, so the report is the agent's.
|
|
674
|
-
if (this._foregroundAgentWorking()) return this.model;
|
|
675
608
|
this.setModel({ name, displayName }, 'report');
|
|
676
609
|
return this.model;
|
|
677
610
|
}
|
|
@@ -702,13 +635,6 @@ export class Session extends EventEmitter {
|
|
|
702
635
|
this._changed();
|
|
703
636
|
}
|
|
704
637
|
|
|
705
|
-
_foregroundAgentWorking() {
|
|
706
|
-
for (const agent of this.agents.values()) {
|
|
707
|
-
if (agent.foreground && agent.status === 'working') return true;
|
|
708
|
-
}
|
|
709
|
-
return false;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
638
|
/**
|
|
713
639
|
* Scan shortly after output pauses, and at least every couple of seconds
|
|
714
640
|
* while output keeps coming, so a busy tool still gets scanned.
|