@technomoron/agent-run 0.99.43 → 0.99.45

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/CHANGES CHANGED
@@ -1,6 +1,25 @@
1
1
  CHANGES
2
2
  =======
3
3
 
4
+ Version 0.99.45 (2026-10-14)
5
+ ----------------------------
6
+ * Move automatic session cleanup out of interactive startup and exit into
7
+ scheduled maintenance, using the hourly AI tools timer on Linux.
8
+ * Add --clean-old and per-user cleanup.env retention settings, defaulting to
9
+ seven days; set AGENT_RUN_SESSION_RETENTION_DAYS=0 to disable retention cleanup.
10
+ * Add hourly Windows session cleanup as a hidden, unelevated scheduled task.
11
+ * Run Linux cleanup as explicitly selected normal users and preserve native
12
+ session deletion and history maintenance.
13
+
14
+ Version 0.99.44 (2026-10-14)
15
+ ----------------------------
16
+ * Remove local agent sessions last modified more than seven days ago before
17
+ startup, including Codex SQLite history, using native deletion where needed.
18
+ * Add --clean to delete all saved sessions for selected agents while preserving
19
+ authentication, settings, skills and brain knowledge.
20
+ * Check the configured brain MCP connection before launching an agent, and
21
+ explain socket ownership and permission failures.
22
+
4
23
  Version 0.99.43 (2026-09-25)
5
24
  ----------------------------
6
25
  * Report startup errors without a stack trace and include file ownership and
package/README.md CHANGED
@@ -281,7 +281,7 @@ Config root:
281
281
 
282
282
  ## Commands
283
283
 
284
- Global flag:
284
+ Global flags:
285
285
 
286
286
  - `-h`, `--help`: show wrapper help for `agent-run` and the built-in commands
287
287
  - `-v`, `--verbose`: print path resolution, file creation, include expansion,
@@ -290,6 +290,35 @@ Global flag:
290
290
  `agent-run codex --help`, `agent-run claude --help`, `agent-run gemini --help`,
291
291
  and `agent-run grok --help` pass `--help` through to the underlying tool.
292
292
 
293
+ Session cleanup runs separately from interactive agent startup and exit. The
294
+ hourly AI tool timer cleans sessions older than seven days by default for selected
295
+ users. Set retention in `~/.agent-run/cleanup.env`:
296
+
297
+ ```sh
298
+ AGENT_RUN_SESSION_RETENTION_DAYS=7
299
+ ```
300
+
301
+ Use a non-negative whole number of days; `0` disables retention cleanup.
302
+ An environment value overrides the file. `--configdir PATH` selects another
303
+ configuration root and its `cleanup.env`. Sessions exactly at the retention
304
+ boundary are retained. Authentication, settings, skills and brain knowledge are
305
+ preserved. Run retention cleanup manually with `agent-run --clean-old all`.
306
+
307
+ Delete all saved sessions for selected agents without launching an agent:
308
+
309
+ ```sh
310
+ agent-run --clean all
311
+ agent-run --clean codex,claude
312
+ agent-run --clean grok gemini
313
+ ```
314
+
315
+ `--configdir PATH` selects the configuration tree to scan. The native user homes
316
+ are also included. Codex and Grok deletion requires an installed CLI with its
317
+ native deletion command; Codex SQLite history is included. Failed or unsupported
318
+ deletions are reported and retained; explicit cleanup exits with status 1 if any
319
+ operation fails. A current session identified in the environment is skipped.
320
+ Symlinks and paths owned by a different user are not cleaned.
321
+
293
322
  For any native agent command, use `--generate` to generate profile files
294
323
  without launching the underlying tool.
295
324
 
@@ -1067,6 +1096,8 @@ The supplied service uses `serve --pull`. On startup this pulls the configuratio
1067
1096
  repository with `--ff-only --no-rebase` when it is clean and has an upstream.
1068
1097
  Dirty checkouts and missing upstreams are skipped. Divergence or network failures
1069
1098
  are reported in the service journal and the service continues with local files.
1099
+ Before launching any configured agent, agent-run initializes its enabled brain MCP connection and calls `brain_status`. If this fails, launch stops with the underlying error. A disabled brain MCP connection also blocks launch when brain is enabled. Run the agent and brain service as the same user; private socket ownership is enforced. `--none` remains an explicit configuration bypass.
1100
+
1070
1101
  Startup never commits, pushes, stashes, or rebases local work. Omit `--pull` to
1071
1102
  disable this behavior for a manually launched service.
1072
1103
 
@@ -1133,6 +1164,25 @@ starts at boot without an interactive login, and restarts it to load the update.
1133
1164
  No accounts are selected by default. The service remains owned by the selected
1134
1165
  user and listens on that user's private Unix socket.
1135
1166
 
1167
+ After a successful timer update, session cleanup runs as each selected user.
1168
+ Set `AI_TOOLS_CLEANUP_USERS="bjorn"` in `/etc/default/ai-tools-update` to select
1169
+ accounts; if unset, it uses `AI_TOOLS_BRAIN_USERS`. An explicitly empty value
1170
+ disables scheduled cleanup. Each user's `~/.agent-run/cleanup.env` controls
1171
+ retention, defaulting to seven days. Failures appear in the systemd journal.
1172
+
1173
+ On Windows, install hourly cleanup for the current user from PowerShell:
1174
+
1175
+ ```powershell
1176
+ $prefix = npm.cmd prefix -g
1177
+ & "$prefix\node_modules\@technomoron\agent-run\scripts\install-windows-cleanup.ps1"
1178
+ ```
1179
+
1180
+ The `Agent Session Cleanup` task runs hidden without elevation while the user is
1181
+ signed in, starting ten minutes after installation and repeating hourly. It uses
1182
+ the same `cleanup.env` and seven-day default. Pass `-ConfigRoot PATH` to select
1183
+ another configuration tree. Its latest output is in
1184
+ `<config-root>/runtime/cleanup.log`. The task skips overlapping runs.
1185
+
1136
1186
  Node 24 or newer must be installed at `/usr/local/bin/node` before running the
1137
1187
  updater. `AI_TOOLS_AGENT_RUN_PACKAGE` can select a package version or a
1138
1188
  root-owned local package archive while testing an unpublished build; it defaults
@@ -116,7 +116,7 @@ function createBrainServer(store) {
116
116
  function requirePrivateSocketDirectory(socketPath) {
117
117
  const stat = fs.lstatSync(path.dirname(socketPath));
118
118
  if (!stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0)
119
- throw new Error('The socket directory must be owned by the current user with mode 0700.');
119
+ throw new Error(`The socket directory ${path.dirname(socketPath)} must be owned by the current user with mode 0700 (current uid ${process.getuid()}, owner uid ${stat.uid}, mode ${(stat.mode & 0o777).toString(8)}). Run agent-run and agent-brain as the same user.`);
120
120
  }
121
121
  async function serveApiCore(configRoot, socketPath) {
122
122
  if (process.platform !== 'win32') {
package/dist/cli.js CHANGED
@@ -12,11 +12,28 @@ function parseInvocation(invokedTool, argv) {
12
12
  let command = normalizeCommandName(invokedTool);
13
13
  const options = extractGlobalOptions(argv);
14
14
  applyGlobalOptions(options);
15
+ if (command === null && options.args[0] && /^(--clean|--clean-old)(=|$)/.test(options.args[0])) {
16
+ return parseCleanCommand(options.args);
17
+ }
15
18
  if (command === null) {
16
19
  command = extractCommand(options.args);
17
20
  }
18
21
  return parseCommand(command, options.args);
19
22
  }
23
+ function parseCleanCommand(args) {
24
+ const flag = args[0];
25
+ const olderOnly = flag === '--clean-old' || flag.startsWith('--clean-old=');
26
+ const name = olderOnly ? '--clean-old' : '--clean';
27
+ const values = [
28
+ ...(flag.includes('=') ? [flag.slice(name.length + 1)] : []),
29
+ ...args.slice(1)
30
+ ].flatMap((value) => value.split(','));
31
+ if (!values.length || values.some((value) => value !== 'all' && !types_1.AGENT_IDS.includes(value))
32
+ || (values.includes('all') && values.length !== 1)) {
33
+ (0, utils_1.fail)(`${name} requires all or a list of codex, claude, gemini, grok`);
34
+ }
35
+ return { command: 'clean', agents: values[0] === 'all' ? [...types_1.AGENT_IDS] : [...new Set(values)], ...(olderOnly ? { olderOnly: true } : {}) };
36
+ }
20
37
  function applyGlobalOptions(options) {
21
38
  if (options.verbose) {
22
39
  process.env[constants_1.VERBOSE_ENV] = '1';
@@ -52,6 +69,8 @@ function parseCommand(command, args) {
52
69
  case 'create':
53
70
  case 'project':
54
71
  return { command, args };
72
+ case 'clean':
73
+ (0, utils_1.fail)('Use agent-run --clean all|codex,claude,gemini,grok');
55
74
  case 'compress':
56
75
  return parseCompressCommand(args);
57
76
  case 'check':
@@ -156,6 +175,8 @@ function parseRunCommand(command, inputArgs) {
156
175
  const args = [];
157
176
  let passthrough = false;
158
177
  for (const arg of inputArgs) {
178
+ if (!passthrough && /^(--clean|--clean-old)(=|$)/.test(arg))
179
+ (0, utils_1.fail)('Use --clean or --clean-old on its own: agent-run --clean-old all|codex,claude,gemini,grok');
159
180
  if (passthrough) {
160
181
  args.push(arg);
161
182
  continue;
@@ -380,6 +401,8 @@ function renderHelp(topic) {
380
401
  ' Run gemini with generated private config',
381
402
  ' grok [--none] [--create] [--local] [--show] [--generate] [--yolo|--sandboxed] [args...]',
382
403
  ' Run grok with generated private config',
404
+ ' --clean all|AGENTS Delete saved sessions for selected agents',
405
+ ' --clean-old all|AGENTS Delete sessions older than configured retention (7 days)',
383
406
  ' compress [--agent NAME] [--scope SCOPE] [--dry-run] Consolidate brain knowledge',
384
407
  ' setup [org/repo] Install the default tree and selected profile',
385
408
  ' check [--all] [path] Validate generated profile output',
package/dist/commands.js CHANGED
@@ -13,6 +13,8 @@ const process_1 = require("./process");
13
13
  const project_1 = require("./project");
14
14
  const renderer_1 = require("./renderer");
15
15
  const spawn_agent_1 = require("./runtime/spawn-agent");
16
+ const brain_preflight_1 = require("./runtime/brain-preflight");
17
+ const clean_sessions_1 = require("./runtime/clean-sessions");
16
18
  const utils_1 = require("./utils");
17
19
  function main(invokedTool, argv) {
18
20
  try {
@@ -25,6 +27,11 @@ function main(invokedTool, argv) {
25
27
  }
26
28
  function dispatch(command) {
27
29
  switch (command.command) {
30
+ case 'clean': {
31
+ const root = (0, project_1.defaultConfigRoot)(process.cwd());
32
+ (0, clean_sessions_1.reportCleanup)((0, clean_sessions_1.cleanSessions)(root, command.agents, !command.olderOnly, Date.now(), command.olderOnly ? (0, clean_sessions_1.sessionRetentionDays)(root) : 7), true);
33
+ return;
34
+ }
28
35
  case 'compress':
29
36
  void runCompress(command).catch((error) => { process.stderr.write(`agent-run: ${(0, utils_1.formatError)(error)}\n`); process.exitCode = 1; });
30
37
  return;
@@ -57,7 +64,7 @@ function dispatch(command) {
57
64
  runMigrateConfig(command);
58
65
  return;
59
66
  default:
60
- runTool(command);
67
+ void runTool(command).catch((error) => { process.stderr.write(`agent-run: ${(0, utils_1.formatError)(error)}\n`); process.exitCode = 1; });
61
68
  }
62
69
  }
63
70
  async function runCompress(command) {
@@ -96,11 +103,11 @@ async function runCompress(command) {
96
103
  if (process.platform === 'win32')
97
104
  prompt = prompt.replace(/\r?\n/g, ' ');
98
105
  // Use the normal adapters, configuration and permission modes. The prompt is one argv value.
99
- runTool({ command: agent, args: agent === 'gemini' ? ['-i', prompt] : [prompt], wrapperArgs: {
106
+ await runTool({ command: agent, args: agent === 'gemini' ? ['-i', prompt] : [prompt], wrapperArgs: {
100
107
  none: false, create: false, local: false, show: false, generate: false, sandboxMode: null, codexNetwork: false
101
108
  } });
102
109
  }
103
- function runTool(parsed) {
110
+ async function runTool(parsed) {
104
111
  const { args, command, wrapperArgs } = parsed;
105
112
  const adapter = (0, registry_1.getAgentAdapter)(command);
106
113
  const projectRoot = (0, project_1.findProjectRoot)(process.cwd());
@@ -144,6 +151,7 @@ function runTool(parsed) {
144
151
  }
145
152
  checkLocalAiFiles(command, projectRoot, agentDir, preview.context, wrapperArgs.local);
146
153
  const realBinary = (0, process_1.findRealBinary)(command);
154
+ await (0, brain_preflight_1.requireBrain)(preview.context);
147
155
  const rendered = (0, renderer_1.syncRenderedProfile)(preview);
148
156
  const runtime = rendered.runtimes[command];
149
157
  if (!runtime) {
package/dist/process.js CHANGED
@@ -9,8 +9,8 @@ const path = require("path");
9
9
  const constants_1 = require("./constants");
10
10
  const utils_1 = require("./utils");
11
11
  const crossSpawn = require('cross-spawn');
12
- function findRealBinary(tool) {
13
- const pathDirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
12
+ function findRealBinary(tool, searchPath = process.env.PATH ?? '') {
13
+ const pathDirs = searchPath.split(path.delimiter).filter(Boolean);
14
14
  const currentScriptArg = process.argv[1];
15
15
  const currentScript = currentScriptArg ? fs.realpathSync(currentScriptArg) : '';
16
16
  const wrappers = wrapperCandidates(tool);
@@ -31,7 +31,7 @@ function findRealBinary(tool) {
31
31
  return candidate;
32
32
  }
33
33
  }
34
- (0, utils_1.fail)(`no ${tool} binary found in PATH`);
34
+ throw new Error(`no ${tool} binary found in PATH`);
35
35
  }
36
36
  function wrapperCandidates(tool) {
37
37
  const names = [
package/dist/project.js CHANGED
@@ -12,6 +12,7 @@ exports.resolveProfile = resolveProfile;
12
12
  exports.resolveProfileResult = resolveProfileResult;
13
13
  exports.parseProfile = parseProfile;
14
14
  exports.projectRootForProfile = projectRootForProfile;
15
+ exports.parseEnvFile = parseEnvFile;
15
16
  exports.defaultConfigRoot = defaultConfigRoot;
16
17
  const childProcess = require("child_process");
17
18
  const fs = require("fs");
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.requireBrain = requireBrain;
4
+ const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
5
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/client/stdio.js");
6
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
7
+ const sse_js_1 = require("@modelcontextprotocol/sdk/client/sse.js");
8
+ const config_1 = require("../brain/config");
9
+ const environment_1 = require("./environment");
10
+ async function requireBrain(context) {
11
+ const server = context.mcpServers['agent-brain'];
12
+ if (!server?.enabled) {
13
+ if ((0, config_1.readBrainConfig)(context.configRoot)?.enabled)
14
+ throw new Error('Cannot start agent: agent-brain is enabled but its MCP connection is disabled or missing.');
15
+ return;
16
+ }
17
+ const client = new index_js_1.Client({ name: 'agent-run-preflight', version: '1.0.0' });
18
+ let diagnostics = '';
19
+ const transport = server.transport === 'stdio'
20
+ ? new stdio_js_1.StdioClientTransport({ command: server.command ?? '', args: server.args, cwd: server.cwd ?? context.projectRoot,
21
+ env: Object.fromEntries(Object.entries({ ...(0, environment_1.buildAgentEnvironment)(context), ...server.env }).filter((entry) => entry[1] !== undefined)), stderr: 'pipe' })
22
+ : server.transport === 'http'
23
+ ? new streamableHttp_js_1.StreamableHTTPClientTransport(new URL(server.url ?? ''), { requestInit: { headers: server.headers } })
24
+ : new sse_js_1.SSEClientTransport(new URL(server.url ?? ''), { requestInit: { headers: server.headers } });
25
+ if (transport instanceof stdio_js_1.StdioClientTransport)
26
+ transport.stderr?.on('data', (chunk) => { diagnostics = (diagnostics + chunk.toString()).slice(-8192); });
27
+ try {
28
+ await client.connect(transport, { timeout: 10000 });
29
+ const status = await client.callTool({ name: 'brain_status', arguments: {} }, undefined, { timeout: 10000 });
30
+ if (status.isError)
31
+ throw new Error(JSON.stringify(status.content));
32
+ }
33
+ catch (error) {
34
+ throw new Error(`Cannot start agent: agent-brain MCP check failed: ${error instanceof Error ? error.message : String(error)}${diagnostics.trim() ? `\n${diagnostics.trim()}` : ''}`);
35
+ }
36
+ finally {
37
+ await client.close();
38
+ await transport.close();
39
+ }
40
+ }
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sessionRetentionDays = sessionRetentionDays;
4
+ exports.cleanSessions = cleanSessions;
5
+ exports.reportCleanup = reportCleanup;
6
+ const fs = require("node:fs");
7
+ const os = require("node:os");
8
+ const path = require("node:path");
9
+ const node_sqlite_1 = require("node:sqlite");
10
+ const cross_spawn_1 = require("cross-spawn");
11
+ const process_1 = require("../process");
12
+ const types_1 = require("../agents/types");
13
+ const config_tree_1 = require("../config-tree");
14
+ const manifest_1 = require("../manifest");
15
+ const templates_1 = require("../templates");
16
+ const project_1 = require("../project");
17
+ const WEEK = 7 * 24 * 60 * 60 * 1000;
18
+ const DAY = WEEK / 7;
19
+ function sessionRetentionDays(configRoot) {
20
+ const file = path.join(configRoot, 'cleanup.env');
21
+ const settings = fs.existsSync(file) ? (0, project_1.parseEnvFile)(fs.readFileSync(file, 'utf8')) : {};
22
+ const value = process.env.AGENT_RUN_SESSION_RETENTION_DAYS ?? settings.AGENT_RUN_SESSION_RETENTION_DAYS ?? '7';
23
+ const days = Number(value);
24
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(days * DAY)) {
25
+ throw new Error('AGENT_RUN_SESSION_RETENTION_DAYS must be a non-negative whole number of days (0 disables retention cleanup).');
26
+ }
27
+ return days;
28
+ }
29
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+ // Do not follow symlinks, including symlinked parent directories or another user's home.
31
+ function owned(file) {
32
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
33
+ if (!stat || stat.isSymbolicLink())
34
+ return undefined;
35
+ if (process.getuid && stat.uid !== process.getuid())
36
+ throw new Error(`Session path belongs to uid ${stat.uid}, current uid is ${process.getuid()}: ${file}`);
37
+ const parent = path.dirname(file);
38
+ if (parent !== file) {
39
+ const parentStat = fs.lstatSync(parent);
40
+ if (parentStat.isSymbolicLink())
41
+ return undefined;
42
+ // Shared ancestors such as /tmp need not belong to the user.
43
+ for (let dir = path.dirname(parent); path.dirname(dir) !== dir; dir = path.dirname(dir)) {
44
+ if (fs.lstatSync(dir).isSymbolicLink())
45
+ return undefined;
46
+ }
47
+ }
48
+ return stat;
49
+ }
50
+ function entries(dir) {
51
+ return owned(dir)?.isDirectory() ? fs.readdirSync(dir, { withFileTypes: true }) : [];
52
+ }
53
+ function files(dir) {
54
+ return entries(dir).flatMap((entry) => {
55
+ const file = path.join(dir, entry.name);
56
+ return entry.isDirectory() ? files(file) : entry.isFile() && owned(file)?.isFile() ? [file] : [];
57
+ });
58
+ }
59
+ function modified(paths) {
60
+ return Math.max(...paths.map((file) => owned(file)?.mtimeMs ?? Infinity));
61
+ }
62
+ function homes(configRoot, agent) {
63
+ const native = process.env[agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : agent === 'gemini' ? 'GEMINI_CLI_HOME' : agent === 'grok' ? 'GROK_HOME' : 'CODEX_HOME'];
64
+ const result = [path.join(os.homedir(), `.${agent}`)];
65
+ if (native)
66
+ result.push(agent === 'gemini' ? path.join(native, '.gemini') : native);
67
+ if (agent === 'codex')
68
+ result.push(path.join(configRoot, 'runtime', 'codex'));
69
+ for (const profile of (0, config_tree_1.findProfileDirs)(configRoot)) {
70
+ const live = path.join(profile, 'live');
71
+ if (agent === 'codex') {
72
+ const profileName = path.relative(configRoot, profile).split(path.sep).join('/');
73
+ const manifest = (0, manifest_1.normalizeManifest)((0, manifest_1.loadManifest)(configRoot, profile, profileName), profileName);
74
+ const context = (0, templates_1.buildRenderContext)((0, project_1.projectRootForProfile)(profileName, configRoot), profile, configRoot, manifest, (0, templates_1.createNunjucksEnv)(configRoot));
75
+ result.push(context.paths.codexHomeDir);
76
+ }
77
+ if (agent === 'gemini')
78
+ result.push(path.join(live, 'gemini', 'home', '.gemini'));
79
+ if (agent === 'grok')
80
+ result.push(path.join(live, 'grok'));
81
+ }
82
+ return [...new Set(result.map((home) => {
83
+ const absolute = path.resolve(home);
84
+ const stat = fs.lstatSync(absolute, { throwIfNoEntry: false });
85
+ // Resolve system aliases such as macOS /var, but never follow a home symlink.
86
+ return stat && !stat.isSymbolicLink() ? fs.realpathSync(absolute) : absolute;
87
+ }))];
88
+ }
89
+ function fileSessions(home, agent) {
90
+ if (agent === 'grok') {
91
+ return entries(path.join(home, 'sessions')).filter((entry) => entry.isDirectory()).flatMap((group) => entries(path.join(home, 'sessions', group.name)).filter((entry) => entry.isDirectory() && UUID.test(entry.name)).map((entry) => {
92
+ const dir = path.join(home, 'sessions', group.name, entry.name);
93
+ return { id: entry.name, files: [dir], modified: modified([dir, ...files(dir)]) };
94
+ }));
95
+ }
96
+ if (agent === 'claude') {
97
+ return entries(path.join(home, 'projects')).filter((entry) => entry.isDirectory()).flatMap((group) => {
98
+ const dir = path.join(home, 'projects', group.name);
99
+ return entries(dir).filter((entry) => entry.isFile() && UUID.test(path.basename(entry.name, '.jsonl')) && entry.name.endsWith('.jsonl')).map((entry) => {
100
+ const id = path.basename(entry.name, '.jsonl');
101
+ const transcript = path.join(dir, entry.name);
102
+ const companions = [path.join(dir, id), path.join(home, 'file-history', id)].filter((file) => owned(file)?.isDirectory());
103
+ return { id, files: [transcript, ...companions], modified: modified([transcript, ...companions.flatMap(files)]) };
104
+ });
105
+ });
106
+ }
107
+ if (agent === 'gemini') {
108
+ return entries(path.join(home, 'tmp')).filter((entry) => entry.isDirectory()).flatMap((group) => {
109
+ const dir = path.join(home, 'tmp', group.name, 'chats');
110
+ return entries(dir).filter((entry) => entry.isFile() && entry.name.endsWith('.json')).map((entry) => {
111
+ const file = path.join(dir, entry.name);
112
+ return { id: entry.name, files: [file], modified: modified([file]) };
113
+ });
114
+ });
115
+ }
116
+ const sessions = new Map();
117
+ for (const dir of ['sessions', 'archived_sessions']) {
118
+ for (const file of files(path.join(home, dir)).filter((file) => file.endsWith('.jsonl'))) {
119
+ const id = path.basename(file, '.jsonl').slice(-36);
120
+ if (!UUID.test(id))
121
+ continue;
122
+ const previous = sessions.get(id);
123
+ sessions.set(id, { id, files: [...previous?.files ?? [], file], modified: Math.max(previous?.modified ?? 0, modified([file])) });
124
+ }
125
+ }
126
+ // Recent Codex keeps paginated history in SQLite; rollout files alone are incomplete.
127
+ for (const entry of entries(home).filter((entry) => entry.isFile() && /^state_\d+\.sqlite$/.test(entry.name))) {
128
+ const db = new node_sqlite_1.DatabaseSync(path.join(home, entry.name), { readOnly: true });
129
+ try {
130
+ for (const row of db.prepare('SELECT id, updated_at FROM threads').all()) {
131
+ if (typeof row.id !== 'string' || !UUID.test(row.id))
132
+ continue;
133
+ const previous = sessions.get(row.id);
134
+ sessions.set(row.id, { id: row.id, files: previous?.files ?? [], modified: Math.max(previous?.modified ?? 0, Number(row.updated_at) * 1000) });
135
+ }
136
+ }
137
+ finally {
138
+ db.close();
139
+ }
140
+ }
141
+ return [...sessions.values()];
142
+ }
143
+ function pruneIndexes(home, agent, ids) {
144
+ const jsonl = agent === 'claude' ? ['history.jsonl'] : agent === 'codex' ? ['history.jsonl', 'session_index.jsonl'] : [];
145
+ for (const name of jsonl) {
146
+ const file = path.join(home, name);
147
+ if (!owned(file)?.isFile())
148
+ continue;
149
+ const original = fs.readFileSync(file, 'utf8');
150
+ const lines = original.split(/(?<=\n)/).filter((line) => {
151
+ try {
152
+ const row = JSON.parse(line);
153
+ return !ids.has(row.sessionId ?? row.session_id ?? row.id);
154
+ }
155
+ catch {
156
+ return true;
157
+ }
158
+ });
159
+ if (lines.join('') !== original)
160
+ fs.writeFileSync(file, lines.join(''));
161
+ }
162
+ if (agent === 'claude') {
163
+ for (const group of entries(path.join(home, 'projects')).filter((entry) => entry.isDirectory())) {
164
+ const file = path.join(home, 'projects', group.name, 'sessions-index.json');
165
+ if (!owned(file)?.isFile())
166
+ continue;
167
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
168
+ if (Array.isArray(data.entries)) {
169
+ data.entries = data.entries.filter((entry) => !ids.has(entry.sessionId ?? ''));
170
+ fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
171
+ }
172
+ }
173
+ }
174
+ }
175
+ function cleanSessions(configRoot, agents = types_1.AGENT_IDS, all = false, now = Date.now(), retentionDays = 7) {
176
+ return agents.map((agent) => {
177
+ const result = { agent, removed: 0, errors: [] };
178
+ if (!all && retentionDays === 0)
179
+ return result;
180
+ let directories;
181
+ try {
182
+ directories = homes(configRoot, agent);
183
+ }
184
+ catch (error) {
185
+ result.errors.push(error instanceof Error ? error.message : String(error));
186
+ return result;
187
+ }
188
+ for (const home of directories) {
189
+ try {
190
+ if (!owned(home)?.isDirectory())
191
+ continue;
192
+ const removed = new Set();
193
+ for (const session of fileSessions(home, agent)) {
194
+ if (!all && session.modified >= now - retentionDays * DAY)
195
+ continue;
196
+ if ([process.env.CODEX_THREAD_ID, process.env.CODEX_SESSION_ID, process.env.CLAUDE_SESSION_ID, process.env.GROK_SESSION_ID].includes(session.id)) {
197
+ if (all)
198
+ result.errors.push(`Skipped current session ${session.id}`);
199
+ continue;
200
+ }
201
+ try {
202
+ if (agent === 'codex' || agent === 'grok') {
203
+ // Let the native CLI remove history and update its own databases/indexes.
204
+ const binary = (0, process_1.findRealBinary)(agent, process.env.AGENT_RUN_REAL_PATH ?? process.env.PATH ?? '');
205
+ const response = (0, cross_spawn_1.sync)(binary, agent === 'codex' ? ['delete', '--force', session.id] : ['sessions', 'delete', session.id], {
206
+ env: { ...process.env, PATH: process.env.AGENT_RUN_REAL_PATH ?? process.env.PATH, [agent === 'codex' ? 'CODEX_HOME' : 'GROK_HOME']: home }, shell: false, encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024
207
+ });
208
+ if (response.error || response.status !== 0)
209
+ throw new Error(response.error?.message ?? (response.stderr || response.stdout || `delete exited ${response.status}`).trim());
210
+ }
211
+ else {
212
+ for (const file of session.files)
213
+ if (owned(file))
214
+ fs.rmSync(file, { recursive: true, force: true });
215
+ }
216
+ if (session.files.some((file) => fs.existsSync(file)))
217
+ throw new Error('Native deletion left session files in place');
218
+ removed.add(session.id);
219
+ result.removed++;
220
+ }
221
+ catch (error) {
222
+ result.errors.push(`${home}: ${session.id}: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
224
+ }
225
+ if (removed.size)
226
+ pruneIndexes(home, agent, removed);
227
+ }
228
+ catch (error) {
229
+ result.errors.push(`${home}: ${error instanceof Error ? error.message : String(error)}`);
230
+ }
231
+ }
232
+ return result;
233
+ });
234
+ }
235
+ function reportCleanup(results, explicit = false) {
236
+ for (const result of results) {
237
+ if (explicit || result.removed)
238
+ process.stdout.write(`${result.agent}: removed ${result.removed} session(s)\n`);
239
+ for (const error of result.errors)
240
+ process.stderr.write(`agent-run: ${result.agent} session cleanup: ${error}\n`);
241
+ }
242
+ if (explicit && results.some((result) => result.errors.length))
243
+ process.exitCode = 1;
244
+ }
@@ -15,3 +15,4 @@ Environment=PNPM_HOME=/usr/local
15
15
  Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
16
16
  EnvironmentFile=-/etc/default/ai-tools-update
17
17
  ExecStart=/usr/local/libexec/agent-run/update-ai-tools
18
+ ExecStartPost=/usr/local/libexec/agent-run/cleanup-agent-sessions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/agent-run",
3
- "version": "0.99.43",
3
+ "version": "0.99.45",
4
4
  "packageManager": "pnpm@10.34.5",
5
5
  "bin": {
6
6
  "agent-run": "dist/agent-run.js",
@@ -17,8 +17,11 @@
17
17
  "scripts/install-systemd-jobs.sh",
18
18
  "scripts/update-ai-tools.sh",
19
19
  "scripts/ensure-agent-brain.sh",
20
+ "scripts/cleanup-agent-sessions.sh",
20
21
  "scripts/brain-pipe-security.ps1",
21
22
  "scripts/install-windows-brain.ps1",
23
+ "scripts/install-windows-cleanup.ps1",
24
+ "scripts/run-windows-cleanup.ps1",
22
25
  "scripts/run-windows-brain.ps1",
23
26
  "scripts/run-hidden.vbs",
24
27
  "README.md",
@@ -36,7 +39,7 @@
36
39
  "cleanbuild": "run-s clean:dist build",
37
40
  "prepack": "run-s build",
38
41
  "test": "run-s build test:codex-profiles test:integration test:release",
39
- "test:codex-profiles": "node --test scripts/test-codex-profiles.cjs",
42
+ "test:codex-profiles": "node --test scripts/test-codex-profiles.cjs scripts/test-session-cleanup.cjs scripts/test-windows-cleanup.cjs",
40
43
  "test:brain": "node --test scripts/test-brain.cjs",
41
44
  "test:integration": "bash ./scripts/test.sh",
42
45
  "test:release": "bash ./scripts/test-release.sh",
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [ "$(id -u)" -ne 0 ]; then
5
+ echo "cleanup-agent-sessions.sh must run as root" >&2
6
+ exit 1
7
+ fi
8
+
9
+ # Select accounts explicitly; never scan every home or run native CLIs as root.
10
+ read -r -a accounts <<<"${AI_TOOLS_CLEANUP_USERS-${AI_TOOLS_BRAIN_USERS:-}}"
11
+ status=0
12
+ for account in "${accounts[@]}"; do
13
+ entry="$(getent passwd "$account")" || { echo "Unknown cleanup account: $account" >&2; status=1; continue; }
14
+ IFS=: read -r username password uid gid gecos account_home login_shell <<<"$entry"
15
+ if [ "$uid" -lt 1000 ] || [ "$uid" -eq 65534 ]; then
16
+ echo "Refusing to clean sessions for system account: $account" >&2
17
+ status=1
18
+ continue
19
+ fi
20
+ # A clean environment avoids leaking root's home, session IDs or native settings.
21
+ if ! runuser -u "$username" -- env -i "HOME=$account_home" "USER=$username" "LOGNAME=$username" \
22
+ PATH=/usr/local/bin:/usr/bin:/bin /bin/sh -c 'cd "$HOME" && exec "$@"' cleanup-agent-sessions /usr/local/bin/agent-run \
23
+ --configdir "$account_home/.agent-run" --clean-old all; then
24
+ status=1
25
+ fi
26
+ done
27
+ exit "$status"
@@ -29,6 +29,7 @@ SERVICE_FILE="$ROOT/ops/systemd/ai-tools-update.service"
29
29
  TIMER_FILE="$ROOT/ops/systemd/ai-tools-update.timer"
30
30
  BRAIN_HELPER="$ROOT/scripts/ensure-agent-brain.sh"
31
31
  BRAIN_SERVICE="$ROOT/ops/systemd/agent-brain.service"
32
+ CLEANUP_HELPER="$ROOT/scripts/cleanup-agent-sessions.sh"
32
33
 
33
34
 
34
35
  INSTALL_AI_TOOLS=0
@@ -82,6 +83,7 @@ run() {
82
83
  run install -d -m 0755 "$INSTALL_LIBEXEC_DIR" "$SYSTEMD_DIR" "$SYSTEMD_USER_DIR"
83
84
  run install -m 0755 "$UPDATE_SCRIPT" "$INSTALL_LIBEXEC_DIR/update-ai-tools"
84
85
  run install -m 0755 "$BRAIN_HELPER" "$INSTALL_LIBEXEC_DIR/ensure-agent-brain"
86
+ run install -m 0755 "$CLEANUP_HELPER" "$INSTALL_LIBEXEC_DIR/cleanup-agent-sessions"
85
87
  run install -m 0644 "$BRAIN_SERVICE" "$SYSTEMD_USER_DIR/agent-brain.service"
86
88
  run install -m 0644 "$SERVICE_FILE" "$SYSTEMD_DIR/ai-tools-update.service"
87
89
  run install -m 0644 "$TIMER_FILE" "$SYSTEMD_DIR/ai-tools-update.timer"
@@ -0,0 +1,14 @@
1
+ param([string]$ConfigRoot = (Join-Path $env:USERPROFILE '.agent-run'))
2
+ $ErrorActionPreference = 'Stop'
3
+ $ConfigRoot = [System.IO.Path]::GetFullPath($ConfigRoot)
4
+ $runner = Join-Path $PSScriptRoot 'run-windows-cleanup.ps1'
5
+ $launcher = Join-Path $PSScriptRoot 'run-hidden.vbs'
6
+ if ($ConfigRoot.Contains('"') -or $runner.Contains('"') -or $launcher.Contains('"')) { throw 'Paths cannot contain quotes.' }
7
+ $wscript = Join-Path $env:SystemRoot 'System32\wscript.exe'
8
+ $account = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
9
+ $action = New-ScheduledTaskAction -Execute $wscript -Argument "//B //NoLogo `"$launcher`" `"$runner`" -ConfigRoot `"$ConfigRoot`"" -WorkingDirectory $env:USERPROFILE
10
+ $principal = New-ScheduledTaskPrincipal -UserId $account -LogonType Interactive -RunLevel Limited
11
+ $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(10) -RepetitionInterval (New-TimeSpan -Hours 1)
12
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -MultipleInstances IgnoreNew
13
+ Register-ScheduledTask -TaskName 'Agent Session Cleanup' -Action $action -Principal $principal -Trigger $trigger -Settings $settings -Description 'Hourly agent-run session retention cleanup for the signed-in user.' -Force | Out-Null
14
+ Write-Host "Agent Session Cleanup registered hourly for $account. Retention: $ConfigRoot\cleanup.env (default 7 days). Log: $ConfigRoot\runtime\cleanup.log"
@@ -0,0 +1,23 @@
1
+ param([Parameter(Mandatory = $true)][string]$ConfigRoot)
2
+ $ErrorActionPreference = 'Stop'
3
+ $ConfigRoot = [System.IO.Path]::GetFullPath($ConfigRoot)
4
+ $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User')
5
+ # Do not inherit a wrapper's guarded PATH or a particular interactive session.
6
+ foreach ($name in @('AGENT_RUN_REAL_PATH', 'CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'CLAUDE_SESSION_ID', 'GROK_SESSION_ID')) {
7
+ [Environment]::SetEnvironmentVariable($name, $null, 'Process')
8
+ }
9
+ Set-Location $env:USERPROFILE
10
+ $runtime = Join-Path $ConfigRoot 'runtime'
11
+ New-Item -ItemType Directory -Path $runtime -Force | Out-Null
12
+ $log = Join-Path $runtime 'cleanup.log'
13
+ $agentRun = Join-Path $PSScriptRoot '..\dist\agent-run.js'
14
+ try {
15
+ $node = (Get-Command node.exe -ErrorAction Stop).Source
16
+ # Native stderr is diagnostic output; the native exit status decides failure.
17
+ $ErrorActionPreference = 'Continue'
18
+ & $node $agentRun --configdir $ConfigRoot --clean-old all *> $log
19
+ exit $LASTEXITCODE
20
+ } catch {
21
+ $_ | Out-File -FilePath $log
22
+ exit 1
23
+ }