@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.
- package/README.md +148 -30
- package/dist/atomic-file.d.ts +30 -0
- package/dist/atomic-file.js +86 -0
- package/dist/briefing.d.ts +6 -0
- package/dist/briefing.js +41 -11
- package/dist/cli.js +238 -26
- package/dist/config.d.ts +39 -1
- package/dist/config.js +126 -3
- package/dist/creation.d.ts +179 -0
- package/dist/creation.js +254 -0
- package/dist/docs.d.ts +34 -0
- package/dist/docs.js +309 -0
- package/dist/doctor.js +123 -21
- package/dist/harness/acp-agent.d.ts +11 -0
- package/dist/harness/acp-agent.js +27 -0
- package/dist/harness/claude-code.d.ts +39 -3
- package/dist/harness/claude-code.js +145 -13
- package/dist/harness/codex.d.ts +7 -1
- package/dist/harness/codex.js +89 -4
- package/dist/harness/registry.d.ts +2 -0
- package/dist/harness/registry.js +19 -0
- package/dist/harness/types.d.ts +59 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/isolation/bubblewrap.js +7 -1
- package/dist/isolation/policy.d.ts +34 -5
- package/dist/isolation/policy.js +114 -7
- package/dist/isolation/resources.d.ts +6 -3
- package/dist/isolation/resources.js +6 -3
- package/dist/isolation/types.d.ts +19 -1
- package/dist/monitor.d.ts +44 -2
- package/dist/monitor.js +177 -42
- package/dist/ops.d.ts +15 -2
- package/dist/ops.js +32 -9
- package/dist/permissions.d.ts +70 -0
- package/dist/permissions.js +97 -0
- package/dist/runner.d.ts +65 -2
- package/dist/runner.js +307 -32
- package/dist/session/acp.d.ts +70 -0
- package/dist/session/acp.js +364 -0
- package/dist/session/control.d.ts +89 -0
- package/dist/session/control.js +322 -0
- package/dist/session/events.d.ts +14 -0
- package/dist/session/events.js +67 -0
- package/dist/session/tmux.d.ts +27 -0
- package/dist/session/tmux.js +76 -0
- package/dist/session/types.d.ts +138 -0
- package/dist/session/types.js +42 -0
- package/dist/spawn.d.ts +32 -2
- package/dist/spawn.js +177 -16
- package/dist/supervisor/launchd.d.ts +50 -0
- package/dist/supervisor/launchd.js +121 -4
- package/dist/supervisor/none.js +22 -4
- package/dist/supervisor/systemd.d.ts +8 -1
- package/dist/supervisor/systemd.js +94 -4
- package/dist/supervisor/types.d.ts +36 -3
- package/dist/tmux.d.ts +34 -2
- package/dist/tmux.js +48 -11
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ harnesses — from one declarative file.**
|
|
|
8
8
|
An AI coding agent in a terminal dies when you close the laptop. `ours-fleet`
|
|
9
9
|
turns such sessions into **roles**: long-lived agents that
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
|
|
11
|
+
- **run through a selectable session backend** — existing detached tmux consoles
|
|
12
|
+
or structured ACP sessions — which you can attach to, peek at, or prompt,
|
|
13
13
|
- are **supervised** — systemd (Linux) or launchd (macOS) restarts them on crash
|
|
14
14
|
and brings them back after a reboot,
|
|
15
15
|
- **resume their context** across restarts (when the harness supports it),
|
|
@@ -44,7 +44,8 @@ roles:
|
|
|
44
44
|
~/fleet.yaml + ~/fleet.d/*.yaml your declaration
|
|
45
45
|
│ ours-fleet up
|
|
46
46
|
▼
|
|
47
|
-
|
|
47
|
+
briefing.md per role ──► tmux session ──► harness CLI (claude …)
|
|
48
|
+
└─► ACP client ──► ACP agent (codex-acp …)
|
|
48
49
|
▲ │
|
|
49
50
|
systemd --user / launchd ───────┘ restart on crash, start at boot/login
|
|
50
51
|
```
|
|
@@ -65,13 +66,16 @@ The state dir contract:
|
|
|
65
66
|
| `WORKLOG.md` | the agent | seeded empty, agent-appended; survives restarts |
|
|
66
67
|
| `ROUTINES.md` | operator / agent | **optional** recurring-work instructions; re-read at the start of every wake, hot-editable **without a restart**; absence means "no routines" |
|
|
67
68
|
| `.identity`, `.cwd`, `.session-id`, `.booted`, `.exit-status`, `.config-path` | supervisor | dot-marker state — session resume and boot bookkeeping |
|
|
69
|
+
| `.monitor-state.json`, `.monitor-status` | supervisor monitor | atomic body-free cursor/pending state and health |
|
|
70
|
+
| `.session-events.jsonl`, `.control.sock`, `.control-token` | ACP backend | bounded typed console projection and private attachment control |
|
|
68
71
|
|
|
69
72
|
## Prerequisites
|
|
70
73
|
|
|
71
74
|
| What | Why | Install |
|
|
72
75
|
|---|---|---|
|
|
73
76
|
| Node ≥ 20 | runs `ours-fleet` itself | nodejs.org, `apt`, or `brew` |
|
|
74
|
-
| tmux |
|
|
77
|
+
| tmux | roles using `session: tmux` (the default) | `apt install tmux` / `brew install tmux` |
|
|
78
|
+
| Node ≥ 22 | Claude roles using `session: acp` | required by the maintained Claude ACP adapter |
|
|
75
79
|
| a harness CLI, logged in | the agent itself | e.g. Claude Code (`claude`) or Codex CLI (`codex`) |
|
|
76
80
|
| `ours-mcp` daemon | identity + agent-to-agent messaging | `npm i -g @ours.network/mcp && ours-mcp start` |
|
|
77
81
|
|
|
@@ -87,6 +91,12 @@ ours-fleet init # units/dirs/linger for this user
|
|
|
87
91
|
ours-fleet doctor # verifies everything above, with actionable messages
|
|
88
92
|
```
|
|
89
93
|
|
|
94
|
+
The maintained Codex and Claude ACP adapters install as optional dependencies of
|
|
95
|
+
`ours-fleet` and are resolved internally; users do not install adapter commands
|
|
96
|
+
or add them to `PATH`. An explicit `session_options.acp.command` remains
|
|
97
|
+
available for custom adapters. On Node 20–21, tmux and Codex ACP remain
|
|
98
|
+
available, while maintained Claude ACP requires upgrading to Node 22.
|
|
99
|
+
|
|
90
100
|
Each OS user manages their own fleet — to host roles under a sandboxed account,
|
|
91
101
|
become that account and repeat.
|
|
92
102
|
|
|
@@ -112,28 +122,18 @@ ours-fleet spawn --temp Scout --mission "one-off research" # gone on exit/rebo
|
|
|
112
122
|
|
|
113
123
|
# Codex role: ours-codex is preferred automatically; plain codex is the fallback
|
|
114
124
|
ours-fleet spawn Coder --harness codex --model gpt-5.4 \
|
|
115
|
-
--
|
|
125
|
+
--session acp --approval ask --filesystem workspace \
|
|
116
126
|
--profile fleet --search --monitor --coordinator FleetCoordinator
|
|
117
127
|
```
|
|
118
128
|
|
|
119
129
|
Permanent spawns are written to `~/fleet.d/<Name>.yaml` — your hand-written
|
|
120
130
|
`~/fleet.yaml` is **never** machine-edited. `ours-fleet rm <Name>` unspawns.
|
|
121
131
|
|
|
122
|
-
From inside Claude Code
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
From inside Codex, install the native fleet plugin:
|
|
128
|
-
|
|
129
|
-
```sh
|
|
130
|
-
npm i -g @ours.network/fleet-codex
|
|
131
|
-
ours-fleet-codex-install
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
Start a new Codex session and say **"spawn an ours agent …"**. The bundled skill
|
|
135
|
-
walks through lifetime, model, sandbox, approval policy, profile, launcher, and
|
|
136
|
-
mail-monitor consent, then verifies the real tmux session and offers oversight.
|
|
132
|
+
From inside Claude Code, Codex, or Hermes with the core `ours` plugin installed,
|
|
133
|
+
say **"spawn an ours agent …"**. The core skill checks for `ours-fleet`, installs
|
|
134
|
+
and initializes it when absent, then consults `ours-fleet docs` for the exact
|
|
135
|
+
version-matched workflow. The older fleet-specific harness packages remain
|
|
136
|
+
published for compatibility but are no longer required or installed by default.
|
|
137
137
|
|
|
138
138
|
## Oversight ("keep an eye")
|
|
139
139
|
|
|
@@ -161,11 +161,12 @@ roles:
|
|
|
161
161
|
## Command reference
|
|
162
162
|
|
|
163
163
|
```
|
|
164
|
+
ours-fleet docs | man AI-friendly complete reference
|
|
164
165
|
ours-fleet up|down|restart|force-restart [-c FILE] [Name...]
|
|
165
166
|
ours-fleet config [-c FILE] validate + print merged plan
|
|
166
167
|
ours-fleet ls | attach | peek | logs [-f] | status <Name>
|
|
167
168
|
ours-fleet send <Name> "text" | --key <K>
|
|
168
|
-
ours-fleet spawn [--temp] <Name> [--harness --mission --model --
|
|
169
|
+
ours-fleet spawn [--temp] <Name> [--harness --session --mission --model --approval ...]
|
|
169
170
|
ours-fleet rm <Name>
|
|
170
171
|
ours-fleet doctor [--harness H]
|
|
171
172
|
ours-fleet init
|
|
@@ -183,6 +184,11 @@ vars: { work_root: /home/me/work } # ${var} substitution anywhere below
|
|
|
183
184
|
start_stagger_ms: 0 # delay between agent LAUNCHES (host-wide, ms); 0 = no stagger
|
|
184
185
|
defaults:
|
|
185
186
|
harness: claude-code # for roles that don't set one
|
|
187
|
+
session: tmux # tmux (default) | acp
|
|
188
|
+
permissions: # common intent, translated by each harness/backend
|
|
189
|
+
approval: ask # ask | allow | deny
|
|
190
|
+
filesystem: workspace # read-only | workspace | unrestricted
|
|
191
|
+
unattended: deny # deny | wait
|
|
186
192
|
model: claude-fable-5 # default model for roles that don't set one (per-role model / --model wins)
|
|
187
193
|
max_tokens: 500000 # session cap (harness-interpreted)
|
|
188
194
|
monitor: # supervisor-owned mail wake (fleet-wide default)
|
|
@@ -190,6 +196,10 @@ defaults:
|
|
|
190
196
|
roles:
|
|
191
197
|
Name: # [A-Za-z0-9_-]+
|
|
192
198
|
harness: claude-code
|
|
199
|
+
session: acp # one flag selects ACP; omit for tmux
|
|
200
|
+
session_options:
|
|
201
|
+
acp:
|
|
202
|
+
command: claude-agent-acp # optional advanced override
|
|
193
203
|
identity: "Display Name" # ours identity to bind (default: Name)
|
|
194
204
|
cwd: ${work_root}/repo # where the harness process runs
|
|
195
205
|
coordinator: FleetCoordinator # announce target on boot
|
|
@@ -236,11 +246,114 @@ roles:
|
|
|
236
246
|
|
|
237
247
|
Merge order: `fleet.yaml` ← `fleet.d/*.yaml`; a duplicate role name is a hard
|
|
238
248
|
error naming both files. Identities and roles are decoupled — removing a role
|
|
239
|
-
never deletes an identity. `
|
|
249
|
+
never deletes an identity. `session` is independent of `harness`, so changing a
|
|
250
|
+
role from tmux to ACP does not change its identity, mission, monitor, or permission
|
|
251
|
+
contract. `defaults.harness_options` is shallow-merged with each
|
|
240
252
|
role's `harness_options`, so a fleet can set common Codex permission/profile defaults
|
|
241
253
|
and override individual keys per role. `monitor` merges the same way — a role block
|
|
242
254
|
overrides `defaults.monitor` key-by-key.
|
|
243
255
|
|
|
256
|
+
### Never-prompt failure
|
|
257
|
+
|
|
258
|
+
An unattended role has no console, so a permission request has nobody to answer
|
|
259
|
+
it and is refused **inside the harness** — no prompt, no error, no log line. The
|
|
260
|
+
agent does less than its briefing told it to, reports success, and nothing
|
|
261
|
+
distinguishes that from having done the work. It is caused by a permission mode
|
|
262
|
+
that suppresses the prompt without granting the action (Claude `dontAsk`), or by
|
|
263
|
+
`unattended: deny`.
|
|
264
|
+
|
|
265
|
+
Automatic decisions are now recorded rather than invisible. Every permission
|
|
266
|
+
decided without a human is written to
|
|
267
|
+
`~/.ours-fleet/agents/<Name>/.session-events.jsonl` with the decision, whether
|
|
268
|
+
policy or a person made it, which policy produced it, the reason, and the option
|
|
269
|
+
chosen — and `ours-fleet peek`/`attach` render them. Automatic denial always
|
|
270
|
+
asks for a one-shot rejection, never a standing one, so one unattended refusal
|
|
271
|
+
cannot disable a tool for the rest of the session. A role that can auto-deny
|
|
272
|
+
says so once at startup.
|
|
273
|
+
|
|
274
|
+
To catch this **before** a role runs, see the capability floor below —
|
|
275
|
+
`ours-fleet doctor` fails an under-permissioned unattended role rather than
|
|
276
|
+
letting it discover the problem silently.
|
|
277
|
+
|
|
278
|
+
### The unattended capability floor
|
|
279
|
+
|
|
280
|
+
A fleet role runs with no console attached, so a permission request has nobody
|
|
281
|
+
to answer it and is refused inside the harness — silently. The agent then does
|
|
282
|
+
less than its briefing told it to and reports no error at all.
|
|
283
|
+
|
|
284
|
+
`ours-fleet config` and `ours-fleet doctor` therefore resolve each role's
|
|
285
|
+
neutral `permissions:` through its harness adapter and check what the resulting
|
|
286
|
+
native settings actually grant, against a fixed floor:
|
|
287
|
+
|
|
288
|
+
| capability | what the role must be able to do |
|
|
289
|
+
| --- | --- |
|
|
290
|
+
| `read-state` | read its briefing, `ROUTINES.md`, and `WORKLOG.md` |
|
|
291
|
+
| `write-state` | append its `WORKLOG.md` and its own state files |
|
|
292
|
+
| `messaging` | bind its identity, send and receive ours mail |
|
|
293
|
+
| `monitor` | arm and observe its mail monitor |
|
|
294
|
+
| `workspace-edit` | edit and test files in its working directory |
|
|
295
|
+
| `status-commands` | run the inspection commands its briefing prescribes |
|
|
296
|
+
|
|
297
|
+
`doctor` reports this per role as `unattended floor: <Role>`. A role configured
|
|
298
|
+
`unattended: deny` that cannot meet the floor **fails** doctor — it would deny
|
|
299
|
+
those requests with nobody to see it. With `unattended: wait` it **warns**,
|
|
300
|
+
since a human can still attach a console and answer.
|
|
301
|
+
|
|
302
|
+
**Security meaning.** `approval: allow` maps to Claude's `bypassPermissions`,
|
|
303
|
+
the mode that actually permits the actions the role was authorized to take.
|
|
304
|
+
`dontAsk` suppresses only the *prompt*, not the denial, which is why an
|
|
305
|
+
`allow` role previously ran unable to do its job. Nothing but an explicit
|
|
306
|
+
`allow` is elevated: `ask` keeps Claude's default mode and `deny` maps to
|
|
307
|
+
`plan`. `allow` is a real grant — give it deliberately, and keep per-role
|
|
308
|
+
[`isolation:`](#agent-isolation) as the outer boundary, which no permission
|
|
309
|
+
mode can cross.
|
|
310
|
+
|
|
311
|
+
### Isolation at creation time
|
|
312
|
+
|
|
313
|
+
```sh
|
|
314
|
+
ours-fleet spawn Sec --isolation-file policy.yaml
|
|
315
|
+
ours-fleet spawn --temp Scout --isolation-file policy.yaml
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
`--isolation-file` supplies the role's sandbox policy when it is created, so its
|
|
319
|
+
**first** launch is already confined. Without it a role gains `isolation:` only when
|
|
320
|
+
you edit `fleet.yaml` and run `up`, and everything before that ran unsandboxed.
|
|
321
|
+
|
|
322
|
+
The file contains exactly the [`isolation:` mapping](#agent-isolation) — the same schema,
|
|
323
|
+
validated by the same code, so it cannot mean something different from the identical block
|
|
324
|
+
in `fleet.yaml`:
|
|
325
|
+
|
|
326
|
+
```yaml
|
|
327
|
+
network: deny
|
|
328
|
+
fs:
|
|
329
|
+
read: [/opt/reference]
|
|
330
|
+
resources:
|
|
331
|
+
mem: 2G
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
An invalid file is rejected before anything is created — no config, no state directory, no
|
|
335
|
+
identity reservation.
|
|
336
|
+
|
|
337
|
+
### Sandboxed credentials and configuration
|
|
338
|
+
|
|
339
|
+
A sandboxed role gets a **per-role writable harness home** under its own state directory
|
|
340
|
+
(`<state>/harness/<harness>/`), so its sessions, history and caches are its own and are
|
|
341
|
+
invisible to every other role. The **shared** credentials, global instructions and
|
|
342
|
+
configuration are layered back read-only:
|
|
343
|
+
|
|
344
|
+
| Harness | Read-only (shared) | Per-role writable |
|
|
345
|
+
| --- | --- | --- |
|
|
346
|
+
| `claude-code` | `~/.claude.json`, `~/.claude/CLAUDE.md`, `settings.json`, `plugins/` | everything else under `~/.claude` |
|
|
347
|
+
| `codex` | `~/.codex/auth.json`, `config.toml`, `AGENTS.md`, `plugins/`, `~/.agents` | everything else under `~/.codex` |
|
|
348
|
+
|
|
349
|
+
An agent can read the credentials it needs and cannot rewrite them, cannot edit the
|
|
350
|
+
instructions every role shares, and cannot alter a peer's configuration. Claude pre-trust
|
|
351
|
+
stays a host-side step performed by the fleet.
|
|
352
|
+
|
|
353
|
+
The forbidden-path list is enforced, not advisory: a role that asks for `~/.ssh`, the ours
|
|
354
|
+
key store, a sibling's state directory — or a parent directory that would expose one, or a
|
|
355
|
+
symlink to one — is refused by `ours-fleet config` before it can launch.
|
|
356
|
+
|
|
244
357
|
### Start staggering
|
|
245
358
|
|
|
246
359
|
`start_stagger_ms` (top-level, host-wide, default `0`) spaces out agent **launches**
|
|
@@ -263,29 +376,34 @@ their boots ~4 s apart instead of firing all seven at once.
|
|
|
263
376
|
|
|
264
377
|
With `monitor.enabled` (the default), the **supervisor** delivers a role's mail
|
|
265
378
|
wakes: the per-role runner long-polls the ours daemon's notification API and
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
379
|
+
submits a single `[fleet-monitor] N new messages from … — run get_messages` prompt
|
|
380
|
+
through the selected backend. ACP uses structured `session/prompt`; tmux uses
|
|
381
|
+
verified console input. It primes the notification cursor *before* the session launches
|
|
269
382
|
(no missed arrivals), cannot be orphaned or left deaf-but-armed, and writes its
|
|
270
383
|
health to `<agentDir>/.monitor-status` (`armed | degraded | failed`), surfaced in
|
|
271
384
|
`ours-fleet status`/`doctor`. Injection is held while the pane shows a modal dialog,
|
|
272
385
|
so an injected wake can never answer a trust/permission prompt; if the dialog is
|
|
273
386
|
still up after 2 minutes the monitor gives up on that wake and records
|
|
274
387
|
`degraded: modal wedge …` instead of waiting silently forever (the mail stays
|
|
275
|
-
queued
|
|
388
|
+
queued and its cursor is not committed until a later delivery is accepted). The agent's
|
|
276
389
|
briefing tells it **not** to arm an
|
|
277
390
|
in-session Monitor. Set `monitor.enabled: false` to keep the legacy behavior where
|
|
278
391
|
the agent arms its own `ours-mcp watch`. `inject: full` (pushing message bodies
|
|
279
392
|
inline) is on the roadmap and needs two new ours-mcp daemon endpoints; today all
|
|
280
393
|
roles deliver `notification` lines and drain via `get_messages`.
|
|
281
394
|
|
|
395
|
+
ACP stdio remains private to the persistent runner. `send`, `peek`, and the basic
|
|
396
|
+
ACP `attach` console use a private, authenticated per-role control socket with
|
|
397
|
+
typed replayable events. This is also the stable extension boundary for a richer
|
|
398
|
+
console later; no terminal UI is part of the monitor or session backend.
|
|
399
|
+
|
|
282
400
|
## Codex roles
|
|
283
401
|
|
|
284
|
-
Install Codex, the native ours plugin, and the fleet
|
|
402
|
+
Install Codex, the native ours plugin, and the fleet CLI once on the fleet host:
|
|
285
403
|
|
|
286
404
|
```sh
|
|
287
|
-
npm i -g @ours.network/fleet
|
|
288
|
-
ours-fleet
|
|
405
|
+
npm i -g @ours.network/fleet
|
|
406
|
+
ours-fleet init
|
|
289
407
|
ours-fleet doctor --harness codex
|
|
290
408
|
```
|
|
291
409
|
|
|
@@ -387,7 +505,7 @@ cleanly as long as each role has its own `cwd` (the common case); two roles
|
|
|
387
505
|
sharing an identical `cwd` could have their resumes cross — give them distinct
|
|
388
506
|
working directories if that matters. MCP and monitor wiring is provided by
|
|
389
507
|
[`@ours.network/codex`](https://github.com/adapt-toolkit/ours-mcp/tree/main/packages/codex);
|
|
390
|
-
the
|
|
508
|
+
the core ours skill discovers fleet behavior through `ours-fleet docs`.
|
|
391
509
|
`ours-fleet doctor --harness codex` verifies the CLI, ours plugin, and enhanced launcher/fallback.
|
|
392
510
|
|
|
393
511
|
## Learn more
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process file protection: a lock, and a replace that cannot leave a
|
|
3
|
+
* partial file behind.
|
|
4
|
+
*
|
|
5
|
+
* Shared state written with plain `readFileSync` → mutate → `writeFileSync` has
|
|
6
|
+
* two failure modes that only appear under concurrency, which is exactly the
|
|
7
|
+
* condition a fleet creates: two starts interleave and one silently discards
|
|
8
|
+
* the other's entry, or a crash mid-write truncates the last good file.
|
|
9
|
+
*/
|
|
10
|
+
export interface LockDeps {
|
|
11
|
+
now?(): number;
|
|
12
|
+
sleep?(ms: number): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Run `fn` holding a cross-process lock. `mkdir` is atomic on every platform we
|
|
16
|
+
* support, so the directory's existence IS the lock; the timestamp inside lets a
|
|
17
|
+
* crashed holder's lock be broken rather than deadlocking the fleet forever.
|
|
18
|
+
* Same strategy as the runner's launch gate, factored out so both use one.
|
|
19
|
+
*
|
|
20
|
+
* The lock is always released, including when `fn` throws.
|
|
21
|
+
*/
|
|
22
|
+
export declare function withFileLock<T>(lockPath: string, fn: () => T | Promise<T>, deps?: LockDeps, staleMs?: number): Promise<T>;
|
|
23
|
+
/**
|
|
24
|
+
* Replace a file's contents atomically: write a temp file in the SAME directory
|
|
25
|
+
* (so the rename cannot cross a filesystem boundary), fsync it, then rename over
|
|
26
|
+
* the target. A reader either sees the old file or the new one — never a
|
|
27
|
+
* half-written one — and an interrupted write leaves the previous contents
|
|
28
|
+
* intact.
|
|
29
|
+
*/
|
|
30
|
+
export declare function replaceFileAtomically(path: string, contents: string, mode?: number): void;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync, } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
const DEFAULT_STALE_MS = 10_000;
|
|
5
|
+
const POLL_MS = 25;
|
|
6
|
+
/**
|
|
7
|
+
* Run `fn` holding a cross-process lock. `mkdir` is atomic on every platform we
|
|
8
|
+
* support, so the directory's existence IS the lock; the timestamp inside lets a
|
|
9
|
+
* crashed holder's lock be broken rather than deadlocking the fleet forever.
|
|
10
|
+
* Same strategy as the runner's launch gate, factored out so both use one.
|
|
11
|
+
*
|
|
12
|
+
* The lock is always released, including when `fn` throws.
|
|
13
|
+
*/
|
|
14
|
+
export async function withFileLock(lockPath, fn, deps = {}, staleMs = DEFAULT_STALE_MS) {
|
|
15
|
+
const now = deps.now ?? (() => Date.now());
|
|
16
|
+
const sleep = deps.sleep ?? ((ms) => new Promise(r => { setTimeout(r, ms); }));
|
|
17
|
+
const stampPath = join(lockPath, 'ts');
|
|
18
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
19
|
+
for (let waited = 0;;) {
|
|
20
|
+
try {
|
|
21
|
+
mkdirSync(lockPath);
|
|
22
|
+
writeFileSync(stampPath, String(now()));
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e.code !== 'EEXIST')
|
|
27
|
+
throw e;
|
|
28
|
+
let held = null;
|
|
29
|
+
try {
|
|
30
|
+
const n = parseInt(readFileSync(stampPath, 'utf8').trim(), 10);
|
|
31
|
+
held = Number.isFinite(n) ? n : null;
|
|
32
|
+
}
|
|
33
|
+
catch { /* holder died between mkdir and stamp */ }
|
|
34
|
+
if ((held !== null && now() - held > staleMs) || waited > staleMs * 2) {
|
|
35
|
+
rmSync(lockPath, { recursive: true, force: true }); // break a dead holder's lock
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
await sleep(POLL_MS);
|
|
39
|
+
waited += POLL_MS;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return await fn();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Replace a file's contents atomically: write a temp file in the SAME directory
|
|
51
|
+
* (so the rename cannot cross a filesystem boundary), fsync it, then rename over
|
|
52
|
+
* the target. A reader either sees the old file or the new one — never a
|
|
53
|
+
* half-written one — and an interrupted write leaves the previous contents
|
|
54
|
+
* intact.
|
|
55
|
+
*/
|
|
56
|
+
export function replaceFileAtomically(path, contents, mode = 0o600) {
|
|
57
|
+
const dir = dirname(path);
|
|
58
|
+
mkdirSync(dir, { recursive: true });
|
|
59
|
+
const tmp = join(dir, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
60
|
+
const fd = openSync(tmp, 'w', mode);
|
|
61
|
+
try {
|
|
62
|
+
writeSync(fd, contents);
|
|
63
|
+
fsyncSync(fd); // the bytes are on disk before anything points at them
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
renameSync(tmp, path);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
rmSync(tmp, { force: true });
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
// Best-effort: make the rename itself durable. Not supported everywhere.
|
|
76
|
+
try {
|
|
77
|
+
const dirFd = openSync(dir, 'r');
|
|
78
|
+
try {
|
|
79
|
+
fsyncSync(dirFd);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
closeSync(dirFd);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { /* platform does not allow fsync on a directory */ }
|
|
86
|
+
}
|
package/dist/briefing.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface BriefingOpts {
|
|
|
6
6
|
routinesPath: string;
|
|
7
7
|
/** Curated body (from briefing_file) replacing the narrative sections. */
|
|
8
8
|
briefingBody?: string;
|
|
9
|
+
/**
|
|
10
|
+
* What spawn actually established about the role's ours identity (7.3).
|
|
11
|
+
* Defaults to `unverified`, because a briefing generated without that
|
|
12
|
+
* knowledge must not claim one.
|
|
13
|
+
*/
|
|
14
|
+
identityGuarantee?: 'verified' | 'created' | 'unverified';
|
|
9
15
|
}
|
|
10
16
|
/** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
|
|
11
17
|
export declare function generateBriefing(role: ResolvedRole, v: BriefingVocab, opts: BriefingOpts): string;
|
package/dist/briefing.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { userInfo } from 'node:os';
|
|
2
|
+
import { oversightTaxonomyLines } from './session/control.js';
|
|
2
3
|
/** Render a role's briefing.md: narrative (or curated body) + mechanical boot steps. */
|
|
3
4
|
export function generateBriefing(role, v, opts) {
|
|
4
5
|
const L = [];
|
|
@@ -22,10 +23,25 @@ export function generateBriefing(role, v, opts) {
|
|
|
22
23
|
}
|
|
23
24
|
L.push('', '## Do these NOW, in order');
|
|
24
25
|
L.push(`1. ${v.launchNote(role.name)}`);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// What this says depends on what spawn actually VERIFIED (7.3). Asserting a
|
|
27
|
+
// "predefined" identity that nobody checked is how an agent ends up improvising
|
|
28
|
+
// its own infrastructure on first boot.
|
|
29
|
+
const guarantee = opts.identityGuarantee ?? 'unverified';
|
|
30
|
+
if (guarantee === 'unverified') {
|
|
31
|
+
L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
|
|
32
|
+
L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
|
|
33
|
+
L.push(` - This identity was NOT verified when your role was created, so it may not exist.`);
|
|
34
|
+
L.push(` If binding reports no such identity, call **${v.createTool}** name "${id}" once`);
|
|
35
|
+
L.push(' to mint it, then you are bound. Re-binding your OWN identity is always allowed.');
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
L.push(`2. BIND your ours identity: call the **${v.bindTool}** tool with`);
|
|
39
|
+
L.push(` name "${id}" force=true (search the deferred tool registry first if needed).`);
|
|
40
|
+
L.push(` - It was ${guarantee === 'created' ? 'created' : 'verified to exist'} when your role`);
|
|
41
|
+
L.push(' was created, so binding should succeed. If it unexpectedly reports no such');
|
|
42
|
+
L.push(` identity, call **${v.createTool}** name "${id}" once and report the discrepancy —`);
|
|
43
|
+
L.push(' something removed it after your role was created.');
|
|
44
|
+
}
|
|
29
45
|
L.push(`3. RECONCILE your profile (idempotent): call **${v.currentIdentityTool}** and read your`);
|
|
30
46
|
L.push(' current bio and persona, so you only write below when they actually differ.');
|
|
31
47
|
L.push(`4. PUBLISH your public **bio** via **${v.setBioTool}**`);
|
|
@@ -50,7 +66,10 @@ export function generateBriefing(role, v, opts) {
|
|
|
50
66
|
L.push(`7. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
|
|
51
67
|
L.push(` call **${v.getMessagesTool}**, act on them,`);
|
|
52
68
|
L.push(` and reply with ${v.sendTool}. No coordinator is configured — the owner drives you`);
|
|
53
|
-
|
|
69
|
+
// NOT `tmux attach -t <name>`: each role's pane lives on its own tmux
|
|
70
|
+
// socket (#32), so a bare attach finds no server. `ours-fleet attach`
|
|
71
|
+
// addresses the right one.
|
|
72
|
+
L.push(` via \`ours-fleet attach ${role.name}\` or by messaging "${id}".`);
|
|
54
73
|
}
|
|
55
74
|
if (role.oversee?.length) {
|
|
56
75
|
L.push('', '## Oversight assignments');
|
|
@@ -58,13 +77,24 @@ export function generateBriefing(role, v, opts) {
|
|
|
58
77
|
for (const o of role.oversee)
|
|
59
78
|
L.push(`- **${o.role}** — check every ${o.interval}`);
|
|
60
79
|
L.push('');
|
|
61
|
-
L.push('Procedure (see also the oversee-agents skill if available)
|
|
80
|
+
L.push('Procedure (see also the oversee-agents skill if available). On each tick, for each ward');
|
|
81
|
+
L.push('run BOTH — they answer different questions:');
|
|
62
82
|
for (const o of role.oversee)
|
|
63
|
-
L.push(`\`ours-fleet peek ${o.role}\``);
|
|
64
|
-
L.push('
|
|
65
|
-
L.push('
|
|
66
|
-
L.push('
|
|
67
|
-
L.push('
|
|
83
|
+
L.push(`\`ours-fleet status ${o.role}\` then \`ours-fleet peek ${o.role}\``);
|
|
84
|
+
L.push('');
|
|
85
|
+
L.push('**One console command is not a liveness verdict.** A `peek` or `send` that fails tells');
|
|
86
|
+
L.push('you what happened to YOUR REQUEST, and only one of its outcomes says the agent is gone.');
|
|
87
|
+
L.push('Read the result you actually got:');
|
|
88
|
+
L.push('');
|
|
89
|
+
L.push(...oversightTaxonomyLines());
|
|
90
|
+
L.push('');
|
|
91
|
+
L.push('Never translate any other failure into "dead". A busy agent, an unanswered control');
|
|
92
|
+
L.push('plane and a confirmed stop look identical if you only look at one command.');
|
|
93
|
+
L.push('');
|
|
94
|
+
L.push('Then judge the console content: stuck on a prompt/menu/trust dialog → answer it directly');
|
|
95
|
+
L.push('with `ours-fleet send <Name> "<text>"` (or `--key <K>` for raw keys); idle with work');
|
|
96
|
+
L.push('assigned → nudge; actively working → do nothing, and do not mistake a long turn for a');
|
|
97
|
+
L.push('stall. Escalate over ours messaging only when you cannot resolve it yourself.');
|
|
68
98
|
}
|
|
69
99
|
L.push('', '## Durable log');
|
|
70
100
|
L.push(`Append important commands / decisions / results to \`${opts.worklogPath}\` as you go —`);
|