axstack 0.36.0 → 0.36.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 CHANGED
@@ -126,10 +126,13 @@ Eligible own PRs use guarded automatic merge; see [merge boundaries, exclusions,
126
126
  ## Some notes
127
127
 
128
128
  - T3 Code is the only supported active runtime. Axstack adds no daemon or runtime
129
- database. The exception is one opt-in systemd user timer `axstack-default-sync`,
130
- running a stateless script that rewrites only T3 `defaultModelSelection`.
131
- It has no workflow state and launches no agent; see
132
- [Host operations](docs/host-operations.md#new-chat-default-sync).
129
+ database. The exceptions are exactly two opt-in systemd user timers:
130
+ `axstack-default-sync` rewrites only T3 `defaultModelSelection`, and
131
+ `axstack-single-server` removes duplicate `t3 serve` process trees for one
132
+ base dir while keeping the recorded server. Both run stateless scripts, keep
133
+ no workflow state and launch no agent; see
134
+ [New-chat default sync](docs/host-operations.md#new-chat-default-sync) and
135
+ [Single T3 server](docs/host-operations.md#single-t3-server).
133
136
  - The recorded owning watch thread merges eligible own PRs; excluded PRs use a merge card.
134
137
  - This is an early project; expect the workflows to evolve.
135
138
 
@@ -44,9 +44,10 @@ the affected operation. Provider/model routing, Linear documents through the
44
44
  executor MCP, and live schedule behavior need separate preflights.
45
45
 
46
46
  Installation creates no production schedule and never enables a timer.
47
- Axstack allows one opt-in systemd user timer `axstack-default-sync` as the
48
- scheduler exception: it runs a stateless script that rewrites only T3
49
- `defaultModelSelection`, has no workflow state and launches no agent.
47
+ Axstack allows exactly two opt-in systemd user timers as scheduler exceptions:
48
+ `axstack-default-sync` rewrites only T3 `defaultModelSelection`, and
49
+ `axstack-single-server` removes duplicate server processes for one base dir.
50
+ Both run stateless scripts, keep no workflow state and launch no agent.
50
51
  Every verified own-PR publication arms or joins the driver's chat-run watch.
51
52
  Its bound T3 schedule uses the cadence in [Chat-run watch activation](#chat-run-watch-activation).
52
53
  See [Chat-run PR watch](workflows.md#chat-run-pr-watch) for authority, schedule identity and stop conditions.
@@ -204,8 +205,9 @@ retirement. No broad scratch deletion or forced worktree removal applies.
204
205
  Manual review and user-driven `axstack-watch` remain outside this schedule.
205
206
  Requested peer reviews cover any accessible repository. T3 owns schedules,
206
207
  threads, runs, and delegated tasks; Axstack adds no queue engine, cursor files,
207
- or historical runtime fallback. The only scheduler exception is the opt-in
208
- systemd user timer described in [New-chat default sync](#new-chat-default-sync).
208
+ or historical runtime fallback. The only scheduler exceptions are the two opt-in
209
+ systemd user timers described in [New-chat default sync](#new-chat-default-sync)
210
+ and [Single T3 server](#single-t3-server).
209
211
 
210
212
  ## Review automation
211
213
 
@@ -282,3 +284,73 @@ Rollback: remove the pool drop-in, reload, and restore the full selection in T3
282
284
  The sibling `<settings>.axstack-default-sync.lock` directory prevents overlap
283
285
  (unchanged/locked). Empty locks older than sixty seconds are removed and retried
284
286
  once; fresh contention reports locked.
287
+
288
+ ## Single T3 server
289
+
290
+ This opt-in timer checks once per minute for this user's `t3 serve` processes
291
+ with the selected base dir (target default `$HOME/.t3`). Each server's base dir
292
+ comes from its `--base-dir <dir>` or `--base-dir=<dir>` flag, then its own
293
+ `T3CODE_HOME`, then its own `$HOME/.t3`, reading the latter values from that
294
+ process's environment. An unreadable or missing environment without an explicit
295
+ flag, or missing base-dir values, makes the whole run hold and kill nothing.
296
+ It keeps the pid recorded in `<base>/userdata/server-runtime.json`.
297
+ With zero or one known matching server it does nothing; with duplicates but no
298
+ valid readable keeper it holds. Duplicate servers must have started strictly
299
+ before the keeper, comparing field 22 (`starttime`) of `/proc/<pid>/stat`.
300
+ An unknown start time or any duplicate started at or after the keeper holds
301
+ the whole run, preserving a possible starting replacement. The script also
302
+ holds if its own pid or an ancestor appears in the planned victim set.
303
+ Otherwise it snapshots descendants before killing each duplicate server first,
304
+ then its children with SIGKILL. The keeper and its descendants are protected.
305
+ It starts no server, reads no database and interrupts no runs automatically.
306
+
307
+ Preview the one-line JSON decision without killing or notifying:
308
+
309
+ ```sh
310
+ bun ~/.agents/skills/axstack/scripts/single-server.js --dry-run
311
+ ```
312
+
313
+ `--base-dir <dir>` selects a different base dir; `--proc-root <dir>` supplies
314
+ a process-tree fixture (default `/proc`). `--help` lists the flags. Decisions
315
+ are `unchanged`, `hold`, `would-kill` or `killed`, with reason, keeper, matching
316
+ server pids and killed pids (proposed victims in dry-run). Non-ESRCH kill failures
317
+ add their pids to `failed`; remaining kills continue, successful kills still
318
+ notify if configured, and the command exits 1. Exit 0 includes holds;
319
+ exit 1 reports invalid input, discovery, kill or notification failure.
320
+
321
+ Under host-mutation authority, copy both assets and enable the timer:
322
+
323
+ ```sh
324
+ mkdir -p ~/.config/systemd/user
325
+ cp ~/.agents/skills/axstack/systemd/axstack-single-server.service ~/.config/systemd/user/axstack-single-server.service
326
+ cp ~/.agents/skills/axstack/systemd/axstack-single-server.timer ~/.config/systemd/user/axstack-single-server.timer
327
+ systemctl --user daemon-reload
328
+ systemctl --user enable --now axstack-single-server.timer
329
+ systemctl --user list-timers axstack-single-server.timer
330
+ journalctl --user -u axstack-single-server.service
331
+ ```
332
+
333
+ Installation only copies assets; it never enables either timer. The single-server
334
+ timer starts one minute after user-manager startup and repeats every minute.
335
+ Override Bun/skills paths or add `--base-dir` through the service's `ExecStart`
336
+ drop-in, clearing the existing `ExecStart` first.
337
+
338
+ To notify only after a kill, use `systemctl --user edit axstack-single-server.service`
339
+ and add a per-host command, then reload:
340
+
341
+ ```ini
342
+ [Service]
343
+ Environment="AXSTACK_SINGLE_SERVER_NOTIFY=hermes send --to telegram --quiet"
344
+ ```
345
+
346
+ The VPS has Hermes locally; other hosts can use
347
+ `Environment="AXSTACK_SINGLE_SERVER_NOTIFY=ssh '<relay-host>' hermes send --to telegram --quiet"`.
348
+ Replace `<relay-host>` with the authorized gateway's hostname.
349
+ The script runs the command via `sh -c`, sending a short message on stdin with
350
+ hostname, kept pid and killed pids. It notes that runs started by the killed
351
+ server may remain `running` and need `t3_thread_interrupt`. Notification failure
352
+ exits 1 after the kills; command output is suppressed. Dry-run, unchanged and
353
+ hold decisions never notify. Verify delivery under the host's notification authority.
354
+
355
+ Disable with `systemctl --user disable --now axstack-single-server.timer` and
356
+ stop any active one-shot with `systemctl --user stop axstack-single-server.service`.
@@ -96,10 +96,13 @@ Run these with Bun from the installed `<skills-dir>/axstack/scripts/` directory:
96
96
  `resolve-models.js` (model IDs), `pick-instance.js` (dispatch account),
97
97
  `pr-digest.js` (PR discovery), `archive-evidence.js` (evidence archive), and
98
98
  `sync-default.js [--settings <path>] [--dry-run]` (New-chat default; `AXSTACK_SYNC_POOL` opts into cross-driver rotation).
99
+ `single-server.js [--base-dir <dir>] [--proc-root <dir>] [--dry-run]` removes
100
+ duplicate `t3 serve` process trees while keeping the recorded runtime pid.
99
101
  `account-usage.js` shares usage retrieval and the five-minute cache between
100
102
  picker and sync. The opt-in systemd user unit files ship in `axstack/systemd/`;
101
- installation copies them as assets and never enables the timer.
102
- See [Host operations](host-operations.md#new-chat-default-sync) for activation.
103
+ installation copies them as assets and never enables either timer.
104
+ See [New-chat default sync](host-operations.md#new-chat-default-sync) and
105
+ [Single T3 server](host-operations.md#single-t3-server) for activation.
103
106
 
104
107
  ## Role presets
105
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axstack",
3
- "version": "0.36.0",
3
+ "version": "0.36.1",
4
4
  "description": "Axstack installer and setup CLI: installs owned chat skills and role data, configures supported harness settings, and checks T3 Code capabilities.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -0,0 +1,164 @@
1
+ import * as fs from 'node:fs';
2
+
3
+ function serverBase(procRoot, pid, argv) {
4
+ let base;
5
+ for (let i = 2; i < argv.length; i++) {
6
+ if (argv[i] === '--base-dir') base = argv[++i];
7
+ else if (argv[i].startsWith('--base-dir=')) base = argv[i].slice('--base-dir='.length);
8
+ }
9
+ if (base !== undefined) return base || null;
10
+ try {
11
+ const environ = fs.readFileSync(`${procRoot}/${pid}/environ`, 'utf8').split('\0');
12
+ const value = (key) => environ.find((entry) => entry.startsWith(`${key}=`))?.slice(key.length + 1);
13
+ return value('T3CODE_HOME') || (value('HOME') ? `${value('HOME')}/.t3` : null);
14
+ } catch { return null; }
15
+ }
16
+
17
+ function discover(procRoot, uid, baseDir) {
18
+ const processes = new Map();
19
+ const servers = [];
20
+ let unknownBase = false;
21
+ const pids = fs.readdirSync(procRoot).filter((name) => /^[1-9]\d*$/.test(name)).map(Number).sort((a, b) => a - b);
22
+ for (const pid of pids) {
23
+ try {
24
+ const status = fs.readFileSync(`${procRoot}/${pid}/status`, 'utf8');
25
+ const parent = /^PPid:\s+(\d+)/m.exec(status);
26
+ const user = /^Uid:\s+(\d+)/m.exec(status);
27
+ if (!parent || !user) throw new Error('invalid process status');
28
+ processes.set(pid, Number(parent[1]));
29
+ if (Number(user[1]) !== uid) continue;
30
+ const argv = fs.readFileSync(`${procRoot}/${pid}/cmdline`, 'utf8').split('\0');
31
+ if (argv[0].split('/').at(-1) !== 't3' || argv[1] !== 'serve') continue;
32
+ const base = serverBase(procRoot, pid, argv);
33
+ if (base === null) unknownBase = true;
34
+ else if (base === baseDir) servers.push(pid);
35
+ } catch (error) {
36
+ // Processes can exit during discovery; every other scan failure prevents kills.
37
+ if (!['ENOENT', 'ESRCH'].includes(error.code)) throw error;
38
+ }
39
+ }
40
+ return { processes, servers, unknownBase };
41
+ }
42
+
43
+ function descendants(pid, processes) {
44
+ const found = new Set([pid]);
45
+ for (const parent of found) {
46
+ for (const [child, ppid] of processes) if (ppid === parent) found.add(child);
47
+ }
48
+ found.delete(pid);
49
+ return [...found];
50
+ }
51
+
52
+ function replacementHold(procRoot, servers, keeper) {
53
+ const starts = new Map(servers.map((pid) => {
54
+ let start = null;
55
+ try {
56
+ const stat = fs.readFileSync(`${procRoot}/${pid}/stat`, 'utf8');
57
+ const end = stat.lastIndexOf(')');
58
+ // The comm field can contain spaces and parentheses; field 22 is index 19 after it.
59
+ const value = end < 0 ? '' : stat.slice(end + 1).trim().split(/\s+/)[19];
60
+ if (/^\d+$/.test(value)) start = BigInt(value);
61
+ } catch { /* Unknown age never authorizes a kill. */ }
62
+ return [pid, start];
63
+ }));
64
+ if ([...starts.values()].includes(null)) return 'unknown-start-time';
65
+ if (servers.some((pid) => pid !== keeper && starts.get(pid) >= starts.get(keeper))) return 'possible-replacement';
66
+ return null;
67
+ }
68
+
69
+ export async function run(args = [], {
70
+ env = process.env, uid = process.getuid(), kill = process.kill, spawn = Bun.spawn,
71
+ hostname = () => fs.readFileSync('/proc/sys/kernel/hostname', 'utf8').trim(), output = console.log,
72
+ } = {}) {
73
+ let decision = { action: 'hold', reason: 'invalid-input', keeper: null, servers: [], killed: [] };
74
+ let failure = 'invalid-input';
75
+ let code = 0;
76
+ try {
77
+ let baseDir = `${env.HOME}/.t3`;
78
+ let procRoot = '/proc';
79
+ let dryRun = false;
80
+ const seen = new Set();
81
+ for (let i = 0; i < args.length; i++) {
82
+ const flag = args[i];
83
+ if (flag === '--help' && args.length === 1) {
84
+ output('Usage: bun single-server.js [--base-dir DIR] [--proc-root DIR] [--dry-run] [--help]\n'
85
+ + 'Defaults: $HOME/.t3 and /proc. SIGKILL duplicate t3 serve servers and their descendants;\n'
86
+ + 'keep the runtime pid. --dry-run reports only. AXSTACK_SINGLE_SERVER_NOTIFY runs via sh -c after kills.');
87
+ return 0;
88
+ }
89
+ if (seen.has(flag)) throw new Error('duplicate flag');
90
+ seen.add(flag);
91
+ if (flag === '--dry-run') dryRun = true;
92
+ else if (['--base-dir', '--proc-root'].includes(flag) && args[i + 1] && !args[i + 1].startsWith('--')) {
93
+ const value = args[++i];
94
+ if (flag === '--base-dir') baseDir = value;
95
+ else procRoot = value;
96
+ } else throw new Error('invalid flag');
97
+ }
98
+ if (!env.HOME) throw new Error('missing home');
99
+ failure = 'scan-failed';
100
+ const { processes, servers, unknownBase } = discover(procRoot, uid, baseDir);
101
+ decision.servers = servers;
102
+ let runtimePid;
103
+ try { runtimePid = JSON.parse(fs.readFileSync(`${baseDir}/userdata/server-runtime.json`, 'utf8')).pid; }
104
+ catch { /* Missing, unreadable or malformed runtime data is never authority to kill. */ }
105
+ if (Number.isSafeInteger(runtimePid) && servers.includes(runtimePid)) decision.keeper = runtimePid;
106
+ const ageHold = !unknownBase && servers.length > 1 && decision.keeper !== null
107
+ ? replacementHold(procRoot, servers, decision.keeper) : null;
108
+ if (unknownBase) decision.reason = 'unknown-base-dir';
109
+ else if (servers.length < 2) decision = { ...decision, action: 'unchanged', reason: 'single-server' };
110
+ else if (decision.keeper === null) decision.reason = 'no-valid-keeper';
111
+ else if (ageHold) decision.reason = ageHold;
112
+ else {
113
+ const protectedPids = new Set([decision.keeper, ...descendants(decision.keeper, processes)]);
114
+ // Snapshot every tree before SIGKILL can reparent children, including other server trees.
115
+ const trees = servers.filter((pid) => !protectedPids.has(pid))
116
+ .map((pid) => [pid, ...descendants(pid, processes)]);
117
+ const roots = trees.filter(([pid]) => !trees.some((tree) => tree[0] !== pid && tree.includes(pid)));
118
+ const victims = [...new Set(roots.flat())].filter((pid) => !protectedPids.has(pid));
119
+ const ancestors = new Set([process.pid]);
120
+ let parent = processes.get(process.pid) ?? process.ppid;
121
+ while (parent && !ancestors.has(parent)) {
122
+ ancestors.add(parent);
123
+ parent = processes.get(parent);
124
+ }
125
+ decision.reason = 'duplicate-servers';
126
+ if (victims.some((pid) => ancestors.has(pid))) decision.reason = 'own-process-tree';
127
+ else if (dryRun) decision = { ...decision, action: 'would-kill', killed: victims };
128
+ else {
129
+ failure = 'kill-failed';
130
+ for (const pid of victims) {
131
+ try { kill(pid, 'SIGKILL'); }
132
+ catch (error) {
133
+ if (error.code !== 'ESRCH') {
134
+ decision.failed ??= [];
135
+ decision.failed.push(pid);
136
+ code = 1;
137
+ }
138
+ continue;
139
+ }
140
+ decision.action = 'killed';
141
+ decision.killed.push(pid);
142
+ }
143
+ if (decision.failed?.length) decision.reason = 'kill-failed';
144
+ else if (!decision.killed.length) decision = { ...decision, action: 'unchanged', reason: 'no-victims' };
145
+ if (decision.killed.length && env.AXSTACK_SINGLE_SERVER_NOTIFY) {
146
+ failure = 'notify-failed';
147
+ const message = `${hostname()}: kept pid ${decision.keeper}; killed pids ${decision.killed.join(', ')}. `
148
+ + 'Runs started by the killed server may stay running and need t3_thread_interrupt.\n';
149
+ const child = spawn(['sh', '-c', env.AXSTACK_SINGLE_SERVER_NOTIFY], {
150
+ stdin: new Blob([message]), stdout: 'ignore', stderr: 'ignore', env,
151
+ });
152
+ if (await child.exited !== 0) throw new Error('notification failed');
153
+ }
154
+ }
155
+ }
156
+ } catch {
157
+ decision.reason = failure;
158
+ code = 1;
159
+ }
160
+ output(JSON.stringify(decision));
161
+ return code;
162
+ }
163
+
164
+ if (import.meta.main) process.exitCode = await run(process.argv.slice(2));
@@ -0,0 +1,8 @@
1
+ [Unit]
2
+ Description=Keep the recorded T3 server and remove duplicate server trees
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ Environment=PATH=%h/.bun/bin:/usr/local/bin:/usr/bin:/bin
7
+ UMask=0077
8
+ ExecStart=/usr/bin/env bun %h/.agents/skills/axstack/scripts/single-server.js
@@ -0,0 +1,10 @@
1
+ [Unit]
2
+ Description=Check for duplicate T3 servers every minute
3
+
4
+ [Timer]
5
+ OnStartupSec=1min
6
+ OnUnitActiveSec=1min
7
+ Unit=axstack-single-server.service
8
+
9
+ [Install]
10
+ WantedBy=timers.target