@ours.network/fleet 0.9.4 → 0.9.7

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.
Files changed (59) hide show
  1. package/README.md +148 -30
  2. package/dist/atomic-file.d.ts +30 -0
  3. package/dist/atomic-file.js +86 -0
  4. package/dist/briefing.d.ts +6 -0
  5. package/dist/briefing.js +41 -11
  6. package/dist/cli.js +238 -26
  7. package/dist/config.d.ts +39 -1
  8. package/dist/config.js +126 -3
  9. package/dist/creation.d.ts +179 -0
  10. package/dist/creation.js +254 -0
  11. package/dist/docs.d.ts +34 -0
  12. package/dist/docs.js +309 -0
  13. package/dist/doctor.js +123 -21
  14. package/dist/harness/acp-agent.d.ts +11 -0
  15. package/dist/harness/acp-agent.js +27 -0
  16. package/dist/harness/claude-code.d.ts +39 -3
  17. package/dist/harness/claude-code.js +145 -13
  18. package/dist/harness/codex.d.ts +7 -1
  19. package/dist/harness/codex.js +89 -4
  20. package/dist/harness/registry.d.ts +2 -0
  21. package/dist/harness/registry.js +19 -0
  22. package/dist/harness/types.d.ts +59 -1
  23. package/dist/index.d.ts +6 -3
  24. package/dist/index.js +3 -1
  25. package/dist/isolation/bubblewrap.js +7 -1
  26. package/dist/isolation/policy.d.ts +34 -5
  27. package/dist/isolation/policy.js +114 -7
  28. package/dist/isolation/resources.d.ts +6 -3
  29. package/dist/isolation/resources.js +6 -3
  30. package/dist/isolation/types.d.ts +19 -1
  31. package/dist/monitor.d.ts +44 -2
  32. package/dist/monitor.js +177 -42
  33. package/dist/ops.d.ts +15 -2
  34. package/dist/ops.js +32 -9
  35. package/dist/permissions.d.ts +70 -0
  36. package/dist/permissions.js +97 -0
  37. package/dist/runner.d.ts +65 -2
  38. package/dist/runner.js +307 -32
  39. package/dist/session/acp.d.ts +70 -0
  40. package/dist/session/acp.js +364 -0
  41. package/dist/session/control.d.ts +89 -0
  42. package/dist/session/control.js +322 -0
  43. package/dist/session/events.d.ts +14 -0
  44. package/dist/session/events.js +67 -0
  45. package/dist/session/tmux.d.ts +27 -0
  46. package/dist/session/tmux.js +76 -0
  47. package/dist/session/types.d.ts +138 -0
  48. package/dist/session/types.js +42 -0
  49. package/dist/spawn.d.ts +32 -2
  50. package/dist/spawn.js +177 -16
  51. package/dist/supervisor/launchd.d.ts +50 -0
  52. package/dist/supervisor/launchd.js +121 -4
  53. package/dist/supervisor/none.js +22 -4
  54. package/dist/supervisor/systemd.d.ts +8 -1
  55. package/dist/supervisor/systemd.js +94 -4
  56. package/dist/supervisor/types.d.ts +36 -3
  57. package/dist/tmux.d.ts +34 -2
  58. package/dist/tmux.js +48 -11
  59. package/package.json +7 -2
@@ -15,6 +15,44 @@ export const busHint = (stderr) => /user scope bus|XDG_RUNTIME_DIR/.test(stderr)
15
15
  `\n (if linger is already on: export XDG_RUNTIME_DIR=/run/user/$(id -u))`
16
16
  : '';
17
17
  export const unitFor = (name) => `ours-fleet-agent@${name}.service`;
18
+ /**
19
+ * systemd's own ActiveState vocabulary, classified. `activating` covers
20
+ * `auto-restart` — the unit is mid-restart, not stopped, so its context stands.
21
+ * `deactivating`/`reloading` still have a process. Only `inactive` and `failed`
22
+ * are definite stops. Anything systemd did not report is `unknown`.
23
+ */
24
+ export function classifyActiveState(activeState) {
25
+ switch (activeState) {
26
+ case 'active':
27
+ case 'activating':
28
+ case 'reloading':
29
+ case 'deactivating': return 'running';
30
+ case 'inactive':
31
+ case 'failed': return 'stopped';
32
+ default: return 'unknown';
33
+ }
34
+ }
35
+ /**
36
+ * Ask the unit what state it is actually in.
37
+ *
38
+ * `show --value` is machine-readable and stable across versions; `status`
39
+ * prose is not, and neither — as it turns out — is an exit code. Shared by
40
+ * `liveness` and by `install`'s start verification so the two cannot disagree
41
+ * about what "running" means.
42
+ */
43
+ async function probeLiveness(ctl, name) {
44
+ const r = await ctl('show', '-p', 'ActiveState', '-p', 'SubState', '--value', unitFor(name));
45
+ const [activeState = '', subState = ''] = r.stdout.trim().split('\n').map(l => l.trim());
46
+ if (!activeState)
47
+ return {
48
+ state: 'unknown',
49
+ detail: `systemctl show ${unitFor(name)} failed: ${r.stderr.trim() || `exit ${r.code}`}${busHint(r.stderr)}`,
50
+ };
51
+ return {
52
+ state: classifyActiveState(activeState),
53
+ detail: subState ? `${activeState} (${subState})` : activeState,
54
+ };
55
+ }
18
56
  export function makeSystemdBackend(exec = realExec) {
19
57
  const ctl = (...args) => exec('systemctl', ['--user', ...args]);
20
58
  return {
@@ -30,8 +68,12 @@ After=default.target
30
68
  [Service]
31
69
  Type=simple
32
70
  ExecStart=${binPath} _run %i
33
- Restart=always
34
- RestartSec=2
71
+ # The RUNNER owns the child-session restart loop, with a counted, backed-off
72
+ # circuit breaker (3.2). systemd must only recover the runner PROCESS crashing —
73
+ # Restart=always here would resume the uncounted two-second relaunch loop, and
74
+ # would also restart a runner that is deliberately holding a failing agent down.
75
+ Restart=on-failure
76
+ RestartSec=5
35
77
  TimeoutStopSec=15
36
78
 
37
79
  [Install]
@@ -46,9 +88,49 @@ WantedBy=default.target
46
88
  return msgs;
47
89
  },
48
90
  async install(name) {
91
+ // Ask FIRST whether this unit was already enabled, so a rollback can tell
92
+ // "we registered this" from "it was already here" (6.2).
93
+ const before = await ctl('is-enabled', unitFor(name));
94
+ const alreadyEnabled = before.stdout.trim() === 'enabled';
49
95
  const r = await ctl('enable', '--now', unitFor(name));
50
- if (r.code !== 0)
96
+ // Undo only what WE enabled. A unit that was already enabled belongs to
97
+ // whoever enabled it, and rollback may never remove that (6.2).
98
+ const undo = async () => { if (!alreadyEnabled)
99
+ await ctl('disable', '--now', unitFor(name)); };
100
+ if (r.code !== 0) {
101
+ // `enable --now` is enable THEN start, so a non-zero result can arrive
102
+ // with the symlink already written. Throwing then means `install` never
103
+ // returns `{created: true}`, the creation transaction records nothing,
104
+ // and a spawn that failed at registration leaves an enabled unit behind.
105
+ await undo();
51
106
  throw new Error(`systemctl enable --now ${unitFor(name)} failed: ${r.stderr.trim()}${busHint(r.stderr)}`);
107
+ }
108
+ // THE EXIT CODE IS NOT THE SIGNAL, so the start is verified rather than
109
+ // assumed.
110
+ //
111
+ // Measured on systemd 255: `systemctl --user enable --now` whose START
112
+ // half fails returns **0** and reports the failed job only as prose on
113
+ // stderr, while a bare `start` of the same unit returns 1. Trusting the
114
+ // code there means a spawn reports success while the role's unit sits
115
+ // enabled and dead — the exact failure this release exists to remove,
116
+ // inside the command that creates the role.
117
+ //
118
+ // Asking the unit its own ActiveState is version-independent: it does not
119
+ // care whether this systemd propagates a start failure into the exit
120
+ // code, so it is correct both on versions that do and versions that do
121
+ // not. Only a DEFINITE stop counts. An unanswerable probe is `unknown`
122
+ // and must never be read as a failed start (1.1) — the unit may be
123
+ // perfectly fine and the bus merely unreachable.
124
+ const live = await probeLiveness(ctl, name);
125
+ if (live.state === 'stopped') {
126
+ await undo();
127
+ throw new Error(`systemctl enable --now ${unitFor(name)} reported success but the unit is not running `
128
+ + `(${live.detail}); systemctl exited 0, which on this version does not report a failed `
129
+ + `start. Check: systemctl --user status ${unitFor(name)}`);
130
+ }
131
+ return alreadyEnabled
132
+ ? { created: false, detail: `${unitFor(name)} was already enabled (${live.detail})` }
133
+ : { created: true, detail: `enabled ${unitFor(name)} (${live.detail})` };
52
134
  },
53
135
  async start(name) { await ctl('start', unitFor(name)); },
54
136
  async stop(name) {
@@ -65,7 +147,15 @@ WantedBy=default.target
65
147
  const r = await ctl('status', unitFor(name), '--no-pager');
66
148
  return r.stdout || r.stderr;
67
149
  },
68
- async uninstall(name) { await ctl('disable', '--now', unitFor(name)); },
150
+ liveness(name) { return probeLiveness(ctl, name); },
151
+ async uninstall(name) {
152
+ const before = await ctl('is-enabled', unitFor(name));
153
+ const wasEnabled = before.stdout.trim() === 'enabled';
154
+ await ctl('disable', '--now', unitFor(name)); // idempotent
155
+ return wasEnabled
156
+ ? { removed: true, detail: `disabled ${unitFor(name)}` }
157
+ : { removed: false, detail: `${unitFor(name)} was not enabled` };
158
+ },
69
159
  logsArgs(name, follow) {
70
160
  return { cmd: 'journalctl', args: ['--user', '-u', unitFor(name), ...(follow ? ['-f'] : ['-n', '200'])] };
71
161
  },
@@ -1,14 +1,47 @@
1
+ /**
2
+ * Typed liveness verdict. `stopped` is a *definite* stop — the only state that
3
+ * lets a caller discard session context. `unknown` means the probe itself did
4
+ * not answer (bus unreachable, missing binary, unrecognised state) and must be
5
+ * treated as "may still be running".
6
+ */
7
+ export type LivenessState = 'running' | 'stopped' | 'unknown';
8
+ export interface Liveness {
9
+ state: LivenessState;
10
+ /** The backend's own words: native state tokens, or the probe failure. */
11
+ detail: string;
12
+ }
13
+ /** Whether `install` had to create the registration, or found it already there. */
14
+ export interface InstallOutcome {
15
+ created: boolean;
16
+ detail: string;
17
+ }
18
+ export interface UninstallOutcome {
19
+ removed: boolean;
20
+ detail: string;
21
+ }
1
22
  export interface SupervisorBackend {
2
23
  id: 'systemd' | 'launchd' | 'none';
3
24
  /** One-time host setup (unit template / dirs / linger). Returns human-readable messages. */
4
25
  init(binPath: string): Promise<string[]>;
5
- /** Ensure the role's unit exists and is enabled + started. */
6
- install(name: string, binPath: string): Promise<void>;
26
+ /**
27
+ * Ensure the role's unit exists and is enabled + started. Idempotent, and
28
+ * EXPLICIT about whether it created the registration: rollback may only
29
+ * remove what this transaction made, so "did I create this?" has to be
30
+ * answerable rather than assumed (6.2).
31
+ */
32
+ install(name: string, binPath: string): Promise<InstallOutcome>;
7
33
  start(name: string): Promise<void>;
8
34
  stop(name: string): Promise<void>;
9
35
  restart(name: string): Promise<void>;
10
36
  status(name: string): Promise<string>;
11
- uninstall(name: string): Promise<void>;
37
+ /**
38
+ * Classify the role's liveness from the backend's own native result — never
39
+ * by matching prose in `status()`. Implementations must not throw: a failed
40
+ * probe is `unknown` with the failure in `detail`.
41
+ */
42
+ liveness(name: string): Promise<Liveness>;
43
+ /** Remove the registration. Idempotent; reports whether anything was there. */
44
+ uninstall(name: string): Promise<UninstallOutcome>;
12
45
  /** Command the CLI execs (stdio inherited) to show logs. */
13
46
  logsArgs(name: string, follow: boolean): {
14
47
  cmd: string;
package/dist/tmux.d.ts CHANGED
@@ -1,14 +1,46 @@
1
1
  import { type Exec } from './exec.js';
2
+ /** Socket-name prefix for every tmux server this fleet starts. */
3
+ export declare const TMUX_SOCKET_PREFIX = "ours-fleet-";
4
+ /**
5
+ * The tmux socket a session lives on — ONE SERVER PER SESSION (#32).
6
+ *
7
+ * Without `-L`, every role's pane lands on the single default tmux server. That
8
+ * server is a child of whichever role happened to start it first, so it sits in
9
+ * that role's unit cgroup: stopping that one unit takes down every other role's
10
+ * pane with it. A per-session socket puts each role's server in its own unit,
11
+ * which is what makes `stop` local to the role being stopped.
12
+ *
13
+ * Role names are `[A-Za-z0-9_-]+` (config.ts), so this is always a usable
14
+ * socket filename.
15
+ */
16
+ export declare const tmuxSocket: (session: string) => string;
17
+ /**
18
+ * Address a tmux command at that session's own server. EVERY tmux invocation in
19
+ * this repository must go through here — one that forgets `-L` silently talks to
20
+ * the shared default server and re-creates #32.
21
+ */
22
+ export declare const tmuxArgs: (session: string, args: string[]) => string[];
2
23
  /** Thin tmux wrapper; all session handling in the core goes through this. */
3
24
  export declare class Tmux {
4
25
  private exec;
5
26
  constructor(exec?: Exec);
6
27
  has(name: string): Promise<boolean>;
7
28
  newSession(name: string, cwd: string, shellCommand: string): Promise<void>;
8
- kill(name: string): Promise<void>;
29
+ /**
30
+ * Best-effort kill. Returns whether a session was actually there to destroy —
31
+ * the caller needs that to tell "we tore this session down" apart from "the
32
+ * program inside it exited on its own".
33
+ */
34
+ kill(name: string): Promise<boolean>;
9
35
  capture(name: string, lines?: number): Promise<string>;
10
36
  panePid(name: string): Promise<number | null>;
11
- list(): Promise<string>;
37
+ /**
38
+ * List the live sessions among `names`, asking each server in turn.
39
+ * There is no fleet-wide `tmux ls` any more: a session per server means the
40
+ * caller says which sessions to ask about. A server that is not running
41
+ * answers non-zero and contributes nothing.
42
+ */
43
+ list(names: readonly string[]): Promise<string>;
12
44
  sendText(name: string, text: string): Promise<void>;
13
45
  sendKey(name: string, key: string): Promise<void>;
14
46
  }
package/dist/tmux.js CHANGED
@@ -1,4 +1,25 @@
1
1
  import { realExec } from './exec.js';
2
+ /** Socket-name prefix for every tmux server this fleet starts. */
3
+ export const TMUX_SOCKET_PREFIX = 'ours-fleet-';
4
+ /**
5
+ * The tmux socket a session lives on — ONE SERVER PER SESSION (#32).
6
+ *
7
+ * Without `-L`, every role's pane lands on the single default tmux server. That
8
+ * server is a child of whichever role happened to start it first, so it sits in
9
+ * that role's unit cgroup: stopping that one unit takes down every other role's
10
+ * pane with it. A per-session socket puts each role's server in its own unit,
11
+ * which is what makes `stop` local to the role being stopped.
12
+ *
13
+ * Role names are `[A-Za-z0-9_-]+` (config.ts), so this is always a usable
14
+ * socket filename.
15
+ */
16
+ export const tmuxSocket = (session) => `${TMUX_SOCKET_PREFIX}${session}`;
17
+ /**
18
+ * Address a tmux command at that session's own server. EVERY tmux invocation in
19
+ * this repository must go through here — one that forgets `-L` silently talks to
20
+ * the shared default server and re-creates #32.
21
+ */
22
+ export const tmuxArgs = (session, args) => ['-L', tmuxSocket(session), ...args];
2
23
  /** Thin tmux wrapper; all session handling in the core goes through this. */
3
24
  export class Tmux {
4
25
  exec;
@@ -6,44 +27,60 @@ export class Tmux {
6
27
  this.exec = exec;
7
28
  }
8
29
  async has(name) {
9
- return (await this.exec('tmux', ['has-session', '-t', name])).code === 0;
30
+ return (await this.exec('tmux', tmuxArgs(name, ['has-session', '-t', name]))).code === 0;
10
31
  }
11
32
  async newSession(name, cwd, shellCommand) {
12
- const r = await this.exec('tmux', ['new-session', '-d', '-s', name, '-c', cwd, shellCommand]);
33
+ const r = await this.exec('tmux', tmuxArgs(name, ['new-session', '-d', '-s', name, '-c', cwd, shellCommand]));
13
34
  if (r.code !== 0)
14
35
  throw new Error(`tmux new-session '${name}' failed (${r.code}): ${r.stderr.trim()}`);
15
36
  }
37
+ /**
38
+ * Best-effort kill. Returns whether a session was actually there to destroy —
39
+ * the caller needs that to tell "we tore this session down" apart from "the
40
+ * program inside it exited on its own".
41
+ */
16
42
  async kill(name) {
17
- await this.exec('tmux', ['kill-session', '-t', name]); // best-effort
43
+ return (await this.exec('tmux', tmuxArgs(name, ['kill-session', '-t', name]))).code === 0;
18
44
  }
19
45
  async capture(name, lines = 40) {
20
- const r = await this.exec('tmux', ['capture-pane', '-t', name, '-p']);
46
+ const r = await this.exec('tmux', tmuxArgs(name, ['capture-pane', '-t', name, '-p']));
21
47
  if (r.code !== 0)
22
48
  throw new Error(`tmux capture-pane '${name}' failed: ${r.stderr.trim()}`);
23
49
  const all = r.stdout.replace(/\n+$/, '').split('\n');
24
50
  return all.slice(-lines).join('\n');
25
51
  }
26
52
  async panePid(name) {
27
- const r = await this.exec('tmux', ['list-panes', '-t', name, '-F', '#{pane_pid}']);
53
+ const r = await this.exec('tmux', tmuxArgs(name, ['list-panes', '-t', name, '-F', '#{pane_pid}']));
28
54
  if (r.code !== 0)
29
55
  return null;
30
56
  const pid = parseInt(r.stdout.trim().split('\n')[0], 10);
31
57
  return Number.isFinite(pid) ? pid : null;
32
58
  }
33
- async list() {
34
- const r = await this.exec('tmux', ['ls']);
35
- return r.code === 0 ? r.stdout.trimEnd() : '';
59
+ /**
60
+ * List the live sessions among `names`, asking each server in turn.
61
+ * There is no fleet-wide `tmux ls` any more: a session per server means the
62
+ * caller says which sessions to ask about. A server that is not running
63
+ * answers non-zero and contributes nothing.
64
+ */
65
+ async list(names) {
66
+ const lines = [];
67
+ for (const name of names) {
68
+ const r = await this.exec('tmux', tmuxArgs(name, ['ls']));
69
+ if (r.code === 0 && r.stdout.trim())
70
+ lines.push(r.stdout.trimEnd());
71
+ }
72
+ return lines.join('\n');
36
73
  }
37
74
  async sendText(name, text) {
38
- let r = await this.exec('tmux', ['send-keys', '-t', name, '-l', text]);
75
+ let r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, '-l', text]));
39
76
  if (r.code !== 0)
40
77
  throw new Error(`tmux send-keys '${name}' failed: ${r.stderr.trim()}`);
41
- r = await this.exec('tmux', ['send-keys', '-t', name, 'Enter']);
78
+ r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, 'Enter']));
42
79
  if (r.code !== 0)
43
80
  throw new Error(`tmux send-keys Enter '${name}' failed: ${r.stderr.trim()}`);
44
81
  }
45
82
  async sendKey(name, key) {
46
- const r = await this.exec('tmux', ['send-keys', '-t', name, key]);
83
+ const r = await this.exec('tmux', tmuxArgs(name, ['send-keys', '-t', name, key]));
47
84
  if (r.code !== 0)
48
85
  throw new Error(`tmux send-keys '${name}' failed: ${r.stderr.trim()}`);
49
86
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.9.4",
4
- "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux consoles, systemd/launchd supervision, ours.network messaging.",
3
+ "version": "0.9.7",
4
+ "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",
7
7
  "repository": {
@@ -26,9 +26,14 @@
26
26
  "prepublishOnly": "npm run build && npm test"
27
27
  },
28
28
  "dependencies": {
29
+ "@agentclientprotocol/sdk": "^1.3.0",
29
30
  "commander": "^12.1.0",
30
31
  "yaml": "^2.5.0"
31
32
  },
33
+ "optionalDependencies": {
34
+ "@agentclientprotocol/claude-agent-acp": "^0.63.0",
35
+ "@agentclientprotocol/codex-acp": "^1.1.7"
36
+ },
32
37
  "devDependencies": {
33
38
  "@types/node": "^20.14.0",
34
39
  "typescript": "^5.5.0",