@ours.network/fleet 0.19.0-nightly.8 → 1.0.0

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 (116) hide show
  1. package/README.md +96 -11
  2. package/dist/application/fleet-query-service.d.ts +1 -1
  3. package/dist/application/fleet-query-service.js +12 -0
  4. package/dist/application/role-creation-service.js +3 -1
  5. package/dist/application/types.d.ts +11 -0
  6. package/dist/briefing.d.ts +1 -1
  7. package/dist/briefing.js +103 -10
  8. package/dist/build-info.json +5 -5
  9. package/dist/cli.js +41 -11
  10. package/dist/config.d.ts +21 -7
  11. package/dist/config.js +32 -18
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/docs.d.ts +2 -2
  14. package/dist/docs.js +145 -24
  15. package/dist/doctor.js +10 -10
  16. package/dist/duration.js +1 -1
  17. package/dist/fleet-proxy.d.ts +5 -0
  18. package/dist/harness/acp-agent.js +11 -6
  19. package/dist/harness/claude-code.js +201 -8
  20. package/dist/harness/codex.d.ts +4 -1
  21. package/dist/harness/codex.js +72 -13
  22. package/dist/harness/types.d.ts +57 -6
  23. package/dist/isolation/bubblewrap.d.ts +4 -4
  24. package/dist/isolation/bubblewrap.js +4 -4
  25. package/dist/isolation/policy.d.ts +1 -1
  26. package/dist/isolation/policy.js +2 -2
  27. package/dist/isolation/registry.d.ts +3 -3
  28. package/dist/isolation/registry.js +5 -5
  29. package/dist/isolation/resources.d.ts +1 -1
  30. package/dist/isolation/resources.js +1 -1
  31. package/dist/isolation/types.d.ts +3 -3
  32. package/dist/loops/manager.d.ts +30 -1
  33. package/dist/loops/manager.js +69 -6
  34. package/dist/loops/state.d.ts +18 -0
  35. package/dist/loops/state.js +4 -0
  36. package/dist/model-env.d.ts +71 -0
  37. package/dist/model-env.js +106 -0
  38. package/dist/monitor.d.ts +2 -2
  39. package/dist/monitor.js +4 -4
  40. package/dist/ops.d.ts +2 -2
  41. package/dist/ops.js +9 -9
  42. package/dist/owner-channel/channel.d.ts +17 -0
  43. package/dist/owner-channel/channel.js +104 -23
  44. package/dist/owner-channel/commands.d.ts +9 -0
  45. package/dist/owner-channel/commands.js +226 -99
  46. package/dist/owner-channel/notices.d.ts +7 -0
  47. package/dist/owner-channel/notices.js +9 -0
  48. package/dist/rooms-tasks/cli.js +733 -221
  49. package/dist/rooms-tasks/close.d.ts +35 -0
  50. package/dist/rooms-tasks/close.js +182 -0
  51. package/dist/rooms-tasks/config.js +8 -5
  52. package/dist/rooms-tasks/cowork-adapter.d.ts +22 -0
  53. package/dist/rooms-tasks/cowork-adapter.js +116 -0
  54. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  55. package/dist/rooms-tasks/external-worker.js +40 -0
  56. package/dist/rooms-tasks/index.d.ts +2 -0
  57. package/dist/rooms-tasks/index.js +2 -0
  58. package/dist/rooms-tasks/markdown.d.ts +49 -0
  59. package/dist/rooms-tasks/markdown.js +206 -0
  60. package/dist/rooms-tasks/member-startup.d.ts +51 -0
  61. package/dist/rooms-tasks/member-startup.js +151 -0
  62. package/dist/rooms-tasks/provision.d.ts +8 -0
  63. package/dist/rooms-tasks/provision.js +363 -97
  64. package/dist/rooms-tasks/room-state.d.ts +13 -1
  65. package/dist/rooms-tasks/room-state.js +149 -1
  66. package/dist/rooms-tasks/task-state.d.ts +13 -2
  67. package/dist/rooms-tasks/task-state.js +131 -3
  68. package/dist/rooms-tasks/terminal.d.ts +23 -0
  69. package/dist/rooms-tasks/terminal.js +56 -0
  70. package/dist/rooms-tasks/types.d.ts +109 -4
  71. package/dist/rooms-tasks/types.js +1 -1
  72. package/dist/runner.d.ts +48 -0
  73. package/dist/runner.js +252 -100
  74. package/dist/session/acp.d.ts +106 -2
  75. package/dist/session/acp.js +217 -14
  76. package/dist/session/activity.d.ts +31 -0
  77. package/dist/session/activity.js +48 -0
  78. package/dist/session/control.d.ts +1 -1
  79. package/dist/session/conversation-normalizer.d.ts +7 -1
  80. package/dist/session/conversation-normalizer.js +154 -11
  81. package/dist/session/conversation-store.js +1 -1
  82. package/dist/session/conversation-types.d.ts +25 -6
  83. package/dist/session/types.d.ts +35 -0
  84. package/dist/spawn.d.ts +4 -2
  85. package/dist/spawn.js +39 -26
  86. package/dist/supervisor/launchd.d.ts +2 -2
  87. package/dist/supervisor/launchd.js +4 -4
  88. package/dist/supervisor/systemd.js +4 -4
  89. package/dist/supervisor/types.d.ts +1 -1
  90. package/dist/temp-lifecycle.d.ts +14 -2
  91. package/dist/temp-lifecycle.js +90 -0
  92. package/dist/watchdog/alerts.d.ts +2 -2
  93. package/dist/watchdog/alerts.js +2 -2
  94. package/dist/watchdog/briefing.d.ts +5 -5
  95. package/dist/watchdog/briefing.js +13 -6
  96. package/dist/watchdog/config.js +1 -1
  97. package/dist/watchdog/query.d.ts +2 -2
  98. package/dist/watchdog/query.js +2 -2
  99. package/dist/watchdog/report.js +2 -2
  100. package/dist/watchdog/run.d.ts +2 -2
  101. package/dist/watchdog/run.js +6 -6
  102. package/dist/watchdog/scheduler.d.ts +7 -7
  103. package/dist/watchdog/scheduler.js +10 -10
  104. package/dist/watchdog/service.d.ts +4 -4
  105. package/dist/watchdog/service.js +5 -6
  106. package/dist/watchdog/store.d.ts +4 -4
  107. package/dist/watchdog/store.js +4 -4
  108. package/dist/web/runtime.js +1 -1
  109. package/dist/web/server.js +1 -1
  110. package/dist/web/topology-promote.js +1 -1
  111. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  112. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  113. package/dist/web-app/index.html +1 -1
  114. package/dist/worklog.d.ts +7 -1
  115. package/dist/worklog.js +191 -39
  116. package/package.json +1 -1
@@ -100,6 +100,9 @@ export class ScheduledLoopStateStore {
100
100
  if (old?.definitionHash === definition.definitionHash) {
101
101
  next[definition.name] = {
102
102
  ...old, promptHash: definition.promptHash, enabled: definition.enabled,
103
+ // A file written before this field existed restores as undefined; an
104
+ // unreported gap is absent, not lost, so normalize rather than trust.
105
+ missedGap: old.missedGap ?? null,
103
106
  };
104
107
  }
105
108
  else {
@@ -119,6 +122,7 @@ export class ScheduledLoopStateStore {
119
122
  activeRunId: old?.activeRunId ?? null,
120
123
  counts: old?.counts ?? zeroCounts(), lastError: old?.lastError ?? null,
121
124
  operatorDisabled: old?.operatorDisabled ?? false,
125
+ missedGap: old?.missedGap ?? null,
122
126
  };
123
127
  }
124
128
  if (recoverActive && next[definition.name].activeRunId) {
@@ -0,0 +1,71 @@
1
+ import type { ResolvedRole } from './config.js';
2
+ /**
3
+ * Which environment variable a harness reads to pin the model it RUNS.
4
+ *
5
+ * This is not a convenience: for `claude-code` it is the only channel that
6
+ * reaches the ACP backend at all. `buildLaunch` (tmux) passes `--model`, but
7
+ * `buildAcpLaunch` launches the ACP adapter with no model argument, and that
8
+ * adapter resolves its model in this order — ANTHROPIC_MODEL, then
9
+ * `settings.model`, then a resumed session's live model, then its first
10
+ * catalogue entry. A role's declared model was therefore invisible to every
11
+ * ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
12
+ * an explicitly requested one.
13
+ */
14
+ export declare const MODEL_ENV_BY_HARNESS: Readonly<Record<string, string>>;
15
+ /** The model-pin variable for a harness, or undefined if it pins no model by env. */
16
+ export declare function modelEnvVar(harness: string | undefined): string | undefined;
17
+ export interface RoleModelEnvInput {
18
+ harness: string;
19
+ /** Already resolved by `resolveRoleModel` — may come from the fleet default. */
20
+ model: string | undefined;
21
+ /** True when the role (or `--model`) named a model, including `model: null`. */
22
+ modelWasExplicit: boolean;
23
+ defaultsEnv?: Record<string, string>;
24
+ roleEnv?: Record<string, string>;
25
+ authProxyBaseUrl?: string;
26
+ }
27
+ export interface RoleModelEnv {
28
+ env: Record<string, string>;
29
+ /**
30
+ * The model the harness will actually run. Equal to `env[pin]` for a harness
31
+ * that pins by env, so anything reporting this value reports the runtime.
32
+ */
33
+ model: string | undefined;
34
+ }
35
+ /**
36
+ * Resolve a role's environment and its runtime model TOGETHER, so the two can
37
+ * never disagree.
38
+ *
39
+ * Precedence, highest first:
40
+ * 1. an explicit `model:` / `--model` on the role
41
+ * 2. the role's own `env:` pin
42
+ * 3. the fleet `defaults.model`
43
+ * 4. the fleet `defaults.env` pin
44
+ *
45
+ * Inheriting the fleet default remains correct when the role names no model
46
+ * (2, 3, 4); an explicitly named one wins (1). Where both are explicit and they
47
+ * disagree, there is no defensible winner, so this refuses rather than picking
48
+ * one silently — the silence is what let a day of "Fable" work run on Opus.
49
+ *
50
+ * `model: null` explicitly asks for no fleet-chosen model, so it also clears an
51
+ * inherited pin instead of leaving one in place to act as a hidden default.
52
+ */
53
+ export declare function resolveRoleModelEnv(input: RoleModelEnvInput, describe?: (message: string) => Error): RoleModelEnv;
54
+ /**
55
+ * The model a role will actually run, read back from the environment it was
56
+ * resolved with. Use this wherever a model is reported to a human.
57
+ */
58
+ export declare function effectiveRoleModel(role: ResolvedRole): string | undefined;
59
+ /**
60
+ * Move a role's env pin onto a new model. Anything that changes the model a
61
+ * role runs after resolution — model-chain recovery is the live example — must
62
+ * go through this, or it changes only the label.
63
+ */
64
+ export declare function repinModelEnv(role: ResolvedRole, model: string | undefined): Record<string, string> | undefined;
65
+ /**
66
+ * Last line of defence, at the exact point a child's environment is composed:
67
+ * refuse to launch a role whose child would run a model other than the one the
68
+ * role declares and the banner reports. A spawn that cannot keep those two in
69
+ * agreement must fail loudly, not start and be believed.
70
+ */
71
+ export declare function assertModelPinReachesChild(role: ResolvedRole, childEnv: Record<string, string | undefined>): void;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Which environment variable a harness reads to pin the model it RUNS.
3
+ *
4
+ * This is not a convenience: for `claude-code` it is the only channel that
5
+ * reaches the ACP backend at all. `buildLaunch` (tmux) passes `--model`, but
6
+ * `buildAcpLaunch` launches the ACP adapter with no model argument, and that
7
+ * adapter resolves its model in this order — ANTHROPIC_MODEL, then
8
+ * `settings.model`, then a resumed session's live model, then its first
9
+ * catalogue entry. A role's declared model was therefore invisible to every
10
+ * ACP role, and a fleet-wide `defaults.env.ANTHROPIC_MODEL` silently outranked
11
+ * an explicitly requested one.
12
+ */
13
+ export const MODEL_ENV_BY_HARNESS = {
14
+ 'claude-code': 'ANTHROPIC_MODEL',
15
+ };
16
+ /** The model-pin variable for a harness, or undefined if it pins no model by env. */
17
+ export function modelEnvVar(harness) {
18
+ return harness === undefined ? undefined : MODEL_ENV_BY_HARNESS[harness];
19
+ }
20
+ /**
21
+ * Resolve a role's environment and its runtime model TOGETHER, so the two can
22
+ * never disagree.
23
+ *
24
+ * Precedence, highest first:
25
+ * 1. an explicit `model:` / `--model` on the role
26
+ * 2. the role's own `env:` pin
27
+ * 3. the fleet `defaults.model`
28
+ * 4. the fleet `defaults.env` pin
29
+ *
30
+ * Inheriting the fleet default remains correct when the role names no model
31
+ * (2, 3, 4); an explicitly named one wins (1). Where both are explicit and they
32
+ * disagree, there is no defensible winner, so this refuses rather than picking
33
+ * one silently — the silence is what let a day of "Fable" work run on Opus.
34
+ *
35
+ * `model: null` explicitly asks for no fleet-chosen model, so it also clears an
36
+ * inherited pin instead of leaving one in place to act as a hidden default.
37
+ */
38
+ export function resolveRoleModelEnv(input, describe = message => new Error(message)) {
39
+ const env = {
40
+ ...(input.defaultsEnv ?? {}),
41
+ ...(input.roleEnv ?? {}),
42
+ ...(input.authProxyBaseUrl ? { ANTHROPIC_BASE_URL: input.authProxyBaseUrl } : {}),
43
+ };
44
+ const pin = modelEnvVar(input.harness);
45
+ if (!pin)
46
+ return { env, model: input.model };
47
+ const rolePin = input.roleEnv?.[pin];
48
+ if (input.modelWasExplicit) {
49
+ if (rolePin !== undefined && rolePin !== input.model)
50
+ throw describe(`model '${input.model ?? '(none)'}' contradicts env.${pin} '${rolePin}'; `
51
+ + `remove one — ${pin} is what the harness actually runs`);
52
+ if (input.model === undefined)
53
+ delete env[pin];
54
+ else
55
+ env[pin] = input.model;
56
+ return { env, model: input.model };
57
+ }
58
+ // Not explicit: a role-level pin is the most specific thing said about this
59
+ // role, so it decides — and the reported model follows it.
60
+ if (rolePin !== undefined)
61
+ return { env, model: rolePin };
62
+ if (input.model !== undefined)
63
+ env[pin] = input.model;
64
+ return { env, model: input.model ?? env[pin] };
65
+ }
66
+ /**
67
+ * The model a role will actually run, read back from the environment it was
68
+ * resolved with. Use this wherever a model is reported to a human.
69
+ */
70
+ export function effectiveRoleModel(role) {
71
+ const pin = modelEnvVar(role.harness);
72
+ return (pin ? role.env?.[pin] : undefined) ?? role.model;
73
+ }
74
+ /**
75
+ * Move a role's env pin onto a new model. Anything that changes the model a
76
+ * role runs after resolution — model-chain recovery is the live example — must
77
+ * go through this, or it changes only the label.
78
+ */
79
+ export function repinModelEnv(role, model) {
80
+ const pin = modelEnvVar(role.harness);
81
+ if (!pin)
82
+ return role.env;
83
+ const env = { ...(role.env ?? {}) };
84
+ if (model === undefined)
85
+ delete env[pin];
86
+ else
87
+ env[pin] = model;
88
+ return Object.keys(env).length ? env : undefined;
89
+ }
90
+ /**
91
+ * Last line of defence, at the exact point a child's environment is composed:
92
+ * refuse to launch a role whose child would run a model other than the one the
93
+ * role declares and the banner reports. A spawn that cannot keep those two in
94
+ * agreement must fail loudly, not start and be believed.
95
+ */
96
+ export function assertModelPinReachesChild(role, childEnv) {
97
+ const pin = modelEnvVar(role.harness);
98
+ if (!pin || role.model === undefined)
99
+ return;
100
+ const actual = childEnv[pin];
101
+ if (actual === role.model)
102
+ return;
103
+ throw new Error(`[${role.name}] refusing to launch: role model is '${role.model}' but the child's `
104
+ + `${pin} is ${actual === undefined ? 'unset' : `'${actual}'`} — the session would run a `
105
+ + 'different model than the one reported');
106
+ }
package/dist/monitor.d.ts CHANGED
@@ -122,7 +122,7 @@ export declare function filterEvents(events: NotifyEvent[], wakeSources: string[
122
122
  /**
123
123
  * Summarize a (coalesced) batch of events into one content-free console line —
124
124
  * count + senders + ids, ending in the call to action. Falls back to compact
125
- * counts when a burst would blow past the length cap (design §3, edge: burst).
125
+ * counts when a burst would exceed the length cap.
126
126
  */
127
127
  export declare function formatNotificationLine(events: NotifyEvent[]): string;
128
128
  /**
@@ -146,7 +146,7 @@ export declare function looksModal(pane: string): boolean;
146
146
  * Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
147
147
  * Claude Code renders a failed turn's tail as an `API Error:` line (a Usage-Policy
148
148
  * refusal, a 4xx, etc.). We scan a generous tail window so the marker survives a
149
- * trailing idle composer redrawn beneath it (design §3.2, refine empirically).
149
+ * trailing idle composer redrawn beneath it; refine empirically.
150
150
  * The N-consecutive threshold in the Monitor debounces the odd false match.
151
151
  */
152
152
  export declare function looksApiError(pane: string): boolean;
package/dist/monitor.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { classifyFailureText } from './model-recovery.js';
5
- // Code constants (not config YAGNI, design §2).
5
+ // Code constants rather than user configuration.
6
6
  const DEFAULT_PORT = 3050;
7
7
  // The daemon normally holds for 25s, but that value is operator-configurable
8
8
  // and synchronous daemon work can delay the response. The former 35s timer
@@ -191,7 +191,7 @@ const plural = (n, one, many = one + 's') => (n === 1 ? one : many);
191
191
  /**
192
192
  * Summarize a (coalesced) batch of events into one content-free console line —
193
193
  * count + senders + ids, ending in the call to action. Falls back to compact
194
- * counts when a burst would blow past the length cap (design §3, edge: burst).
194
+ * counts when a burst would exceed the length cap.
195
195
  */
196
196
  export function formatNotificationLine(events) {
197
197
  const of = (t) => events.filter(e => e.event === t);
@@ -229,7 +229,7 @@ export function formatNotificationLine(events) {
229
229
  ].filter(Boolean).join(', ');
230
230
  return `${PREFIX} ${compact} — run get_messages`;
231
231
  }
232
- // ─── Modal-dialog detection (design §3.2, refined empirically) ────────────────
232
+ // ─── Modal-dialog detection, refined empirically ─────────────────────────────
233
233
  //
234
234
  // `❯` is Claude Code's ordinary composer prompt, so it is on screen in nearly
235
235
  // every capture. Testing for it *anywhere* in the pane therefore says nothing;
@@ -285,7 +285,7 @@ export function looksModal(pane) {
285
285
  * Heuristic: did the turn shown in this pane TERMINATE in an API-level error?
286
286
  * Claude Code renders a failed turn's tail as an `API Error:` line (a Usage-Policy
287
287
  * refusal, a 4xx, etc.). We scan a generous tail window so the marker survives a
288
- * trailing idle composer redrawn beneath it (design §3.2, refine empirically).
288
+ * trailing idle composer redrawn beneath it; refine empirically.
289
289
  * The N-consecutive threshold in the Monitor debounces the odd false match.
290
290
  */
291
291
  export function looksApiError(pane) {
package/dist/ops.d.ts CHANGED
@@ -21,12 +21,12 @@ export interface OpsDeps {
21
21
  * Called the INSTANT a registration is created, before anything else can
22
22
  * fail. A creation transaction that learns about registrations only from
23
23
  * `up()`'s return value learns nothing when `up()` throws — and the service
24
- * it just registered is then invisible to rollback (6.2). Optional: plain
24
+ * it just registered is then invisible to rollback. Optional: plain
25
25
  * `ours-fleet up` has no transaction to tell.
26
26
  */
27
27
  onInstalled?(outcome: InstallOutcome): void;
28
28
  /**
29
- * Optional: supervises the single watchdog-scheduler process (Task 10).
29
+ * Optional: supervises the single watchdog-scheduler process.
30
30
  * Absent for callers that predate watchdogs — `up`/`down` must never throw
31
31
  * just because this hook is missing.
32
32
  */
package/dist/ops.js CHANGED
@@ -17,7 +17,7 @@ import { daemonIdentityProvisioner, reconcilePermanentRoleIdentities, } from './
17
17
  /** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
18
18
  export function applyRole(role, opts = {}) {
19
19
  const adapter = getAdapter(role.harness);
20
- const errs = adapter.validateOptions(role.harness_options);
20
+ const errs = adapter.validateOptions(role.harness_options, role);
21
21
  if (errs.length)
22
22
  throw new Error(`role '${role.name}': ` + errs.map(e => `${e.path}: ${e.message}`).join('; '));
23
23
  const dir = agentDir(role.name, opts.temp === true);
@@ -62,7 +62,7 @@ export async function up(cfg, names, deps, configPath, identityGuarantee) {
62
62
  // guessing "stopped" from an unanswered probe silently discards a live
63
63
  // conversation.
64
64
  // An explicit operator `up` is the sanctioned way to release a held-down
65
- // role: the still-alive runner polls this file and resumes (3.2).
65
+ // role: the still-alive runner polls this file and resumes.
66
66
  resetRestartLedger(dir);
67
67
  const live = await deps.backend.liveness(role.name)
68
68
  .catch(e => ({ state: 'unknown', detail: e instanceof Error ? e.message : String(e) }));
@@ -71,7 +71,7 @@ export async function up(cfg, names, deps, configPath, identityGuarantee) {
71
71
  else if (live.state === 'unknown')
72
72
  deps.log(` ! ${role.name}: liveness unknown, keeping session context — ${live.detail}`);
73
73
  // Report what each install actually did, so a creation transaction can undo
74
- // only the registrations IT made (6.2). Announced immediately as well as
74
+ // only the registrations IT made. Announced immediately as well as
75
75
  // returned: a later role in this same loop can throw, and the registrations
76
76
  // already made must still be undoable.
77
77
  const outcome = { ...await deps.backend.install(role.name, deps.binPath), role: role.name };
@@ -94,7 +94,7 @@ function stableStringify(value) {
94
94
  return v;
95
95
  });
96
96
  }
97
- /** sha256 over the resolved enabled-watchdog set, stable regardless of config source order (finding #4). */
97
+ /** SHA-256 over the resolved enabled-watchdog set, stable regardless of config source order. */
98
98
  function watchdogFingerprint(enabled) {
99
99
  const sorted = [...enabled].sort((a, b) => a.name.localeCompare(b.name));
100
100
  return createHash('sha256').update(stableStringify(sorted)).digest('hex');
@@ -117,14 +117,14 @@ function writeFingerprint(fingerprint) {
117
117
  }
118
118
  /**
119
119
  * Install/start (or stop) the single supervised watchdog-scheduler process
120
- * (Task 10) to match the config's enabled watchdogs. Runs on every `up`,
120
+ * to match the config's enabled watchdogs. Runs on every `up`,
121
121
  * including a named `up <Role>` — cheap and idempotent, and the alternative
122
122
  * (only reconciling on a whole-fleet `up`) would leave a newly-enabled
123
123
  * watchdog unscheduled until the next bare `up`. Never throws: a scheduler
124
124
  * hiccup must not fail the role installs that already succeeded.
125
125
  *
126
126
  * Restarts ONLY when something the scheduler actually needs to pick up
127
- * changed (finding #4): an unconditional `restart()` on every `up` —
127
+ * changed: an unconditional `restart()` on every `up` —
128
128
  * including a named `up <SomeUnrelatedRole>` — interrupted whatever the
129
129
  * scheduler was mid-run (a 15s stop timeout can kill a long check without a
130
130
  * report). Two independent signals decide: `svc.install()`'s own `changed`
@@ -142,7 +142,7 @@ async function reconcileWatchdogScheduler(cfg, deps, configPath) {
142
142
  return;
143
143
  const enabled = cfg.watchdogs.filter(w => w.enabled);
144
144
  try {
145
- // Check supervised() before any stop (final review #3): on an
145
+ // Check supervised() before any stop: on an
146
146
  // unsupervised platform/config, the scheduler was never installed, so
147
147
  // stopping it is not just a no-op — on Linux, stop() throws for a unit
148
148
  // that was never loaded. A watchdog-less fleet must not see that
@@ -162,7 +162,7 @@ async function reconcileWatchdogScheduler(cfg, deps, configPath) {
162
162
  if (unitChanged || fingerprintChanged) {
163
163
  // restart(), not start(): start() is a no-op on an already-active unit,
164
164
  // so a config change (new/changed watchdogs) would never reach a
165
- // scheduler that's already running (final review #4).
165
+ // scheduler that's already running.
166
166
  await svc.restart();
167
167
  deps.log(`↑ watchdogs scheduler (${enabled.map(w => w.name).join(', ')})`);
168
168
  }
@@ -208,7 +208,7 @@ export async function down(cfg, names, deps) {
208
208
  // Only a whole-fleet `down` (no names) stops the scheduler — stopping one
209
209
  // named role is not a decision to stop watching the others (tolerate
210
210
  // absence/errors: a never-installed scheduler must not fail `down`).
211
- // supervised() gates the call itself (final review #3): on an
211
+ // supervised() gates the call itself: on an
212
212
  // unsupervised platform/config there is nothing installed to stop, and
213
213
  // Linux's stop() throws for a unit that was never loaded.
214
214
  if (names.length === 0 && deps.watchdogService?.supervised()) {
@@ -217,6 +217,13 @@ export declare class OwnerChannel implements OwnerChannelHandle {
217
217
  * CLI is asked to bounce this very process; neither can happen afterwards.
218
218
  */
219
219
  private restartSelf;
220
+ /**
221
+ * The caller may itself be a room member. Persist and acknowledge acceptance
222
+ * before an external worker starts a saga that can retire this process.
223
+ */
224
+ private closeRoomFromOwner;
225
+ /** Carry a task terminal request through a worker that survives this role. */
226
+ private terminalTaskFromOwner;
220
227
  /** Code-point-safe tail of the worklog, or undefined when there is none. */
221
228
  private readWorklogTail;
222
229
  /** Bound harness-command output to a single outbound message. */
@@ -250,6 +257,16 @@ export declare class OwnerChannel implements OwnerChannelHandle {
250
257
  private warnOwnerOfUnauthorizedSender;
251
258
  private effectiveOwners;
252
259
  private authorizationIntegrity;
260
+ /**
261
+ * Report what the session actually did with the prompt, not what the config
262
+ * asked for. `interrupt: true` used to be reported as "your request
263
+ * interrupted the previous task" unconditionally; the session now answers
264
+ * whether anything was cancelled, whether the request is queued behind
265
+ * earlier prompts, or whether it is held until the current task reaches a
266
+ * safe stopping point. Backends that report no delivery state keep the old
267
+ * queuedBehind-based wording.
268
+ */
269
+ private acceptanceNotice;
253
270
  private complete;
254
271
  private commentsState;
255
272
  /** Model-authored commentary only; raw protocol/tool data never reaches here. */
@@ -6,6 +6,7 @@ import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
6
6
  import { replaceFileAtomically } from '../atomic-file.js';
7
7
  import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
8
8
  import { VERSION } from '../version.js';
9
+ import { renderMarkdownFailure, renderMarkdownResult, taskStatus, } from '../rooms-tasks/markdown.js';
9
10
  import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
10
11
  import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
11
12
  import { ownerNotices, } from './notices.js';
@@ -195,10 +196,13 @@ export class OwnerChannel {
195
196
  ? ' with interruption'
196
197
  : event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
197
198
  const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
199
+ const permission = event.permissionMode
200
+ ? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
201
+ : '';
198
202
  const inherited = event.inherited.length
199
203
  ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
200
204
  await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
201
- + `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
205
+ + `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
202
206
  });
203
207
  this.managementTail = run.then(() => undefined, () => undefined);
204
208
  return run;
@@ -789,17 +793,13 @@ export class OwnerChannel {
789
793
  outbox = this.outboxDir(originWireId);
790
794
  await mkdir(outbox, { recursive: true, mode: 0o700 });
791
795
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
792
- const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, outbox, admitted, group.caption), {
796
+ const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
793
797
  interrupt: this.options.config.interrupt,
794
798
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
795
799
  origin: { kind: 'owner', requestId,
796
800
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
797
801
  });
798
- const accepted = this.options.config.interrupt
799
- ? ownerNotices.receivedInterrupting()
800
- : queued.queuedBehind > 0
801
- ? ownerNotices.receivedQueued(queued.queuedBehind)
802
- : ownerNotices.receivedStarted();
802
+ const accepted = this.acceptanceNotice(queued);
803
803
  handledWireIds.forEach(wire => this.inFlight.add(wire));
804
804
  const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
805
805
  this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -918,7 +918,7 @@ export class OwnerChannel {
918
918
  let queued;
919
919
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
920
920
  try {
921
- queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
921
+ queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
922
922
  interrupt: this.options.config.interrupt,
923
923
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
924
924
  origin: { kind: 'owner', requestId, displayText: text },
@@ -940,14 +940,7 @@ export class OwnerChannel {
940
940
  this.state.remember(wireId);
941
941
  return true;
942
942
  }
943
- // Interrupting the live turn does not remove prompts which were already
944
- // accepted into the ACP queue. Never claim this request is running while
945
- // the session itself says earlier work remains ahead of it.
946
- const accepted = queued.queuedBehind > 0
947
- ? ownerNotices.receivedQueued(queued.queuedBehind)
948
- : this.options.config.interrupt
949
- ? ownerNotices.receivedInterrupting()
950
- : ownerNotices.receivedStarted();
943
+ const accepted = this.acceptanceNotice(queued);
951
944
  this.inFlight.add(wireId);
952
945
  const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
953
946
  this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -996,6 +989,8 @@ export class OwnerChannel {
996
989
  return this.commentsState();
997
990
  },
998
991
  fleetList: () => this.fleetOps.list(),
992
+ closeRoom: roomId => this.closeRoomFromOwner(sender, roomId, wireId),
993
+ terminalTask: (taskId, kind, outcome) => this.terminalTaskFromOwner(sender, taskId, kind, outcome, wireId),
999
994
  recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
1000
995
  readWorklogTail: maxChars => this.readWorklogTail(maxChars),
1001
996
  reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
@@ -1046,6 +1041,60 @@ export class OwnerChannel {
1046
1041
  this.options.log(`[${this.options.role}] owner requested ${command}`);
1047
1042
  await this.fleetOps.restart(mode);
1048
1043
  }
1044
+ /**
1045
+ * The caller may itself be a room member. Persist and acknowledge acceptance
1046
+ * before an external worker starts a saga that can retire this process.
1047
+ */
1048
+ async closeRoomFromOwner(sender, roomId, wireId) {
1049
+ const { acceptManagedRoomClose, recordManagedRoomCloseError } = await import('../rooms-tasks/close.js');
1050
+ await acceptManagedRoomClose(roomId);
1051
+ await this.send(sender.id, renderMarkdownFailure({
1052
+ kind: 'pending', subject: `/room delete ${roomId} ${roomId}`,
1053
+ detail: 'The deletion request was accepted and is still being settled.',
1054
+ action: `Run /room recover ${roomId} if deletion remains pending.`,
1055
+ }), wireId);
1056
+ this.state.remember(wireId);
1057
+ try {
1058
+ await this.fleetOps.closeRoom(roomId);
1059
+ }
1060
+ catch (error) {
1061
+ await recordManagedRoomCloseError(roomId, error instanceof Error ? error.message : String(error), `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.`);
1062
+ throw error;
1063
+ }
1064
+ }
1065
+ /** Carry a task terminal request through a worker that survives this role. */
1066
+ async terminalTaskFromOwner(sender, taskId, kind, outcome, wireId) {
1067
+ const { getTask } = await import('../rooms-tasks/task-state.js');
1068
+ const { acceptTaskTerminalIntent, recordTaskTerminalIntentError, } = await import('../rooms-tasks/terminal.js');
1069
+ const task = getTask(taskId);
1070
+ const accepted = await acceptTaskTerminalIntent({
1071
+ taskId, kind, roomId: task.room_id, outcome,
1072
+ });
1073
+ if (accepted.terminal_intent?.status === 'settled') {
1074
+ await this.send(sender.id, renderMarkdownResult({
1075
+ icon: '📋', title: 'Task terminal action complete',
1076
+ fields: [
1077
+ { label: 'ID', value: taskId, kind: 'code' },
1078
+ { label: 'Status', value: taskStatus(accepted.state), kind: 'markdown' },
1079
+ ],
1080
+ }), wireId);
1081
+ this.state.remember(wireId);
1082
+ return;
1083
+ }
1084
+ await this.send(sender.id, renderMarkdownFailure({
1085
+ kind: 'pending', subject: `/task ${kind === 'done' ? 'done' : 'cancel'} ${taskId}`,
1086
+ detail: 'The terminal request was accepted and is still being settled.',
1087
+ action: `Run /task recover ${taskId} if it remains pending.`,
1088
+ }), wireId);
1089
+ this.state.remember(wireId);
1090
+ try {
1091
+ await this.fleetOps.settleTask(taskId);
1092
+ }
1093
+ catch (error) {
1094
+ await recordTaskTerminalIntentError(taskId, error instanceof Error ? error.message : String(error), `External settle worker failed to start. Retry the identical task command or run task recover ${taskId}.`);
1095
+ throw error;
1096
+ }
1097
+ }
1049
1098
  /** Code-point-safe tail of the worklog, or undefined when there is none. */
1050
1099
  async readWorklogTail(maxChars) {
1051
1100
  try {
@@ -1332,6 +1381,32 @@ export class OwnerChannel {
1332
1381
  authorizationIntegrity() {
1333
1382
  return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
1334
1383
  }
1384
+ /**
1385
+ * Report what the session actually did with the prompt, not what the config
1386
+ * asked for. `interrupt: true` used to be reported as "your request
1387
+ * interrupted the previous task" unconditionally; the session now answers
1388
+ * whether anything was cancelled, whether the request is queued behind
1389
+ * earlier prompts, or whether it is held until the current task reaches a
1390
+ * safe stopping point. Backends that report no delivery state keep the old
1391
+ * queuedBehind-based wording.
1392
+ */
1393
+ acceptanceNotice(queued) {
1394
+ switch (queued.delivery) {
1395
+ case 'interrupted': return ownerNotices.receivedInterrupting();
1396
+ case 'deferred': return ownerNotices.receivedDeferred();
1397
+ case 'queued': return ownerNotices.receivedQueued(Math.max(1, queued.queuedBehind));
1398
+ case 'started': return ownerNotices.receivedStarted();
1399
+ default:
1400
+ // Interrupting the live turn does not remove prompts which were already
1401
+ // accepted into the ACP queue. Never claim this request is running while
1402
+ // the session itself says earlier work remains ahead of it.
1403
+ return queued.queuedBehind > 0
1404
+ ? ownerNotices.receivedQueued(queued.queuedBehind)
1405
+ : this.options.config.interrupt
1406
+ ? ownerNotices.receivedInterrupting()
1407
+ : ownerNotices.receivedStarted();
1408
+ }
1409
+ }
1335
1410
  async complete(active, outbox, queued, activityCursor) {
1336
1411
  const progressMs = this.options.config.progress_interval_ms;
1337
1412
  let lastSeq = activityCursor;
@@ -1532,7 +1607,7 @@ export class OwnerChannel {
1532
1607
  throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
1533
1608
  return message;
1534
1609
  }
1535
- ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
1610
+ ownerAttachmentPrompt(sender, wireId, requestId, files, caption) {
1536
1611
  const lines = [
1537
1612
  '[fleet-owner]',
1538
1613
  `Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
@@ -1556,10 +1631,13 @@ export class OwnerChannel {
1556
1631
  lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
1557
1632
  }
1558
1633
  }
1559
- lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.', `To attach response files, write regular files only to: ${outbox}`);
1634
+ lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.',
1635
+ // send_file is the only delivery route an agent is given: a tool call either
1636
+ // delivers or reports an error, where a file written to disk does neither.
1637
+ 'To send the owner a file — now or later in this turn — call ours `send_file`:', `contact: ${this.options.config.identity}`, 'and the path of the finished file. Fleet routes it to the authenticated owner.', 'A file written anywhere else is not delivered and nothing will report that it was not.', 'Use descriptive unique filenames. Send nothing the owner did not request or should not receive.');
1560
1638
  return lines.join('\n');
1561
1639
  }
1562
- ownerPrompt(sender, text, wireId, outbox) {
1640
+ ownerPrompt(sender, text, wireId) {
1563
1641
  return [
1564
1642
  '[fleet-owner]',
1565
1643
  `Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
@@ -1573,10 +1651,13 @@ export class OwnerChannel {
1573
1651
  ] : [
1574
1652
  'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
1575
1653
  ]),
1576
- 'To attach files to your response, copy each finished file directly into this fleet outbox:',
1577
- outbox,
1578
- 'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
1579
- 'Use descriptive unique filenames. Put nothing there that the owner did not request or should not receive.',
1654
+ // send_file is the only delivery route an agent is given: a tool call either
1655
+ // delivers or reports an error, where a file written to disk does neither.
1656
+ 'To send the owner a file now or later in this turn call ours `send_file`:',
1657
+ `contact: ${this.options.config.identity}`,
1658
+ 'and the path of the finished file. Fleet routes it to the authenticated owner.',
1659
+ 'A file written anywhere else is not delivered and nothing will report that it was not.',
1660
+ 'Use descriptive unique filenames. Send nothing the owner did not request or should not receive.',
1580
1661
  '',
1581
1662
  text || '(empty message)',
1582
1663
  ].join('\n');
@@ -1,4 +1,5 @@
1
1
  import type { InterruptOutcome, SessionEvent, SessionSnapshot } from '../session/types.js';
2
+ import type { TaskOutcome, TaskTerminalIntent } from '../rooms-tasks/types.js';
2
3
  import { type OwnerCommentsState } from './notices.js';
3
4
  /**
4
5
  * Fleet-level effects a deterministic owner command may trigger. Production
@@ -10,6 +11,10 @@ export interface OwnerFleetOps {
10
11
  restart(mode: 'keep' | 'fresh'): Promise<void>;
11
12
  /** `ours-fleet ls` output. */
12
13
  list(): Promise<string>;
14
+ /** Start a room-close worker outside the caller role's supervisor lifecycle. */
15
+ closeRoom(roomId: string): Promise<void>;
16
+ /** Resume a task terminal intent outside the caller role's supervisor lifecycle. */
17
+ settleTask(taskId: string): Promise<void>;
13
18
  }
14
19
  /**
15
20
  * The narrow capability surface a command executor sees. Everything here is
@@ -40,6 +45,10 @@ export interface OwnerCommandContext {
40
45
  */
41
46
  setComments(enabled: boolean): OwnerCommentsState;
42
47
  fleetList(): Promise<string>;
48
+ /** Persist acceptance, acknowledge it, then launch the external close worker. */
49
+ closeRoom(roomId: string): Promise<void>;
50
+ /** Persist terminal intent, acknowledge it, then launch the external settle worker. */
51
+ terminalTask(taskId: string, kind: TaskTerminalIntent['kind'], outcome?: TaskOutcome): Promise<void>;
43
52
  recentEvents(limit: number): SessionEvent[];
44
53
  readWorklogTail(maxChars: number): Promise<string | undefined>;
45
54
  reply(text: string): Promise<void>;