@ours.network/fleet 0.10.3 → 0.11.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 (94) hide show
  1. package/README.md +66 -0
  2. package/dist/application/capabilities.d.ts +6 -0
  3. package/dist/application/capabilities.js +37 -0
  4. package/dist/application/errors.d.ts +31 -0
  5. package/dist/application/errors.js +51 -0
  6. package/dist/application/fleet-query-service.d.ts +42 -0
  7. package/dist/application/fleet-query-service.js +188 -0
  8. package/dist/application/log-service.d.ts +28 -0
  9. package/dist/application/log-service.js +146 -0
  10. package/dist/application/role-command-service.d.ts +37 -0
  11. package/dist/application/role-command-service.js +82 -0
  12. package/dist/application/role-creation-service.d.ts +142 -0
  13. package/dist/application/role-creation-service.js +374 -0
  14. package/dist/application/role-repository.d.ts +20 -0
  15. package/dist/application/role-repository.js +168 -0
  16. package/dist/application/session-control.d.ts +55 -0
  17. package/dist/application/session-control.js +115 -0
  18. package/dist/application/types.d.ts +156 -0
  19. package/dist/application/types.js +1 -0
  20. package/dist/cli.js +341 -3
  21. package/dist/config.d.ts +9 -2
  22. package/dist/config.js +21 -5
  23. package/dist/creation.d.ts +11 -0
  24. package/dist/creation.js +22 -5
  25. package/dist/docs.d.ts +1 -1
  26. package/dist/docs.js +56 -0
  27. package/dist/duration.d.ts +5 -0
  28. package/dist/duration.js +20 -0
  29. package/dist/index.d.ts +10 -1
  30. package/dist/index.js +9 -1
  31. package/dist/ops.d.ts +16 -0
  32. package/dist/ops.js +112 -3
  33. package/dist/paths.d.ts +1 -0
  34. package/dist/paths.js +1 -0
  35. package/dist/resolved-plan.js +7 -0
  36. package/dist/runner.js +10 -2
  37. package/dist/session/control.d.ts +4 -2
  38. package/dist/session/control.js +45 -13
  39. package/dist/spawn.d.ts +20 -2
  40. package/dist/spawn.js +94 -24
  41. package/dist/supervisor/launchd.js +17 -0
  42. package/dist/supervisor/none.js +17 -0
  43. package/dist/supervisor/systemd.js +4 -0
  44. package/dist/supervisor/types.d.ts +6 -0
  45. package/dist/tmux.d.ts +2 -0
  46. package/dist/tmux.js +8 -0
  47. package/dist/watchdog/alerts.d.ts +34 -0
  48. package/dist/watchdog/alerts.js +78 -0
  49. package/dist/watchdog/briefing.d.ts +65 -0
  50. package/dist/watchdog/briefing.js +181 -0
  51. package/dist/watchdog/config.d.ts +49 -0
  52. package/dist/watchdog/config.js +114 -0
  53. package/dist/watchdog/query.d.ts +78 -0
  54. package/dist/watchdog/query.js +124 -0
  55. package/dist/watchdog/report.d.ts +53 -0
  56. package/dist/watchdog/report.js +126 -0
  57. package/dist/watchdog/run.d.ts +61 -0
  58. package/dist/watchdog/run.js +318 -0
  59. package/dist/watchdog/scheduler.d.ts +105 -0
  60. package/dist/watchdog/scheduler.js +244 -0
  61. package/dist/watchdog/service.d.ts +46 -0
  62. package/dist/watchdog/service.js +179 -0
  63. package/dist/watchdog/store.d.ts +85 -0
  64. package/dist/watchdog/store.js +226 -0
  65. package/dist/web/audit.d.ts +22 -0
  66. package/dist/web/audit.js +54 -0
  67. package/dist/web/auth.d.ts +61 -0
  68. package/dist/web/auth.js +186 -0
  69. package/dist/web/control.d.ts +14 -0
  70. package/dist/web/control.js +110 -0
  71. package/dist/web/device-store.d.ts +27 -0
  72. package/dist/web/device-store.js +155 -0
  73. package/dist/web/events.d.ts +15 -0
  74. package/dist/web/events.js +34 -0
  75. package/dist/web/lock.d.ts +5 -0
  76. package/dist/web/lock.js +69 -0
  77. package/dist/web/runtime.d.ts +12 -0
  78. package/dist/web/runtime.js +214 -0
  79. package/dist/web/server.d.ts +37 -0
  80. package/dist/web/server.js +279 -0
  81. package/dist/web/service.d.ts +42 -0
  82. package/dist/web/service.js +180 -0
  83. package/dist/web/terminal/bridge.d.ts +27 -0
  84. package/dist/web/terminal/bridge.js +317 -0
  85. package/dist/web-app/assets/TerminalView-BvcIkuIF.js +9 -0
  86. package/dist/web-app/assets/index-B-jtLAkp.css +1 -0
  87. package/dist/web-app/assets/index-CUN7ksTw.js +9 -0
  88. package/dist/web-app/icons/ours-fleet-maskable.svg +4 -0
  89. package/dist/web-app/icons/ours-fleet.svg +4 -0
  90. package/dist/web-app/index.html +17 -0
  91. package/dist/web-app/manifest.webmanifest +15 -0
  92. package/dist/web-app/offline.html +18 -0
  93. package/dist/web-app/sw.js +51 -0
  94. package/package.json +26 -3
package/dist/spawn.js CHANGED
@@ -4,18 +4,33 @@ import { join } from 'node:path';
4
4
  import { parse, stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
6
  import { validateIsolationConfig } from './isolation/policy.js';
7
- import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveWorklogPolicy, } from './config.js';
7
+ import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
8
8
  import { applyRole, up } from './ops.js';
9
9
  import { START_STAGGER_FILE } from './runner.js';
10
10
  import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
11
11
  import { VERSION } from './version.js';
12
+ import './harness/claude-code.js';
13
+ import './harness/codex.js';
12
14
  import { getAdapter } from './harness/registry.js';
13
15
  /**
14
16
  * The provenance record written by the most recent spawn in this process, so
15
17
  * the CLI can print the same summary it persisted rather than rebuilding it.
16
18
  */
17
19
  export let lastProvenance;
18
- function roleFromOpts(o, defaultHarness) {
20
+ export function profileValues(o) {
21
+ if (o.bio !== undefined && o.bioFile)
22
+ throw new Error('bio and bioFile are mutually exclusive');
23
+ if (o.persona !== undefined && o.personaFile)
24
+ throw new Error('persona and personaFile are mutually exclusive');
25
+ return {
26
+ bio: o.bio !== undefined ? o.bio.trim()
27
+ : o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
28
+ persona: o.persona !== undefined ? o.persona.trim()
29
+ : o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined,
30
+ };
31
+ }
32
+ /** Pure option-to-role mapping shared by CLI and application services. */
33
+ export function buildRoleConfig(o, defaultHarness) {
19
34
  const r = {};
20
35
  if (o.harness)
21
36
  r.harness = o.harness;
@@ -31,7 +46,9 @@ function roleFromOpts(o, defaultHarness) {
31
46
  r.mission = readMissionFile(o.missionFile);
32
47
  else if (o.mission !== undefined)
33
48
  r.mission = o.mission;
34
- if (o.model?.trim())
49
+ if (o.model === null)
50
+ r.model = null;
51
+ else if (o.model?.trim())
35
52
  r.model = o.model.trim();
36
53
  const harness = o.harness ?? defaultHarness;
37
54
  const harnessOptions = {};
@@ -60,12 +77,15 @@ function roleFromOpts(o, defaultHarness) {
60
77
  ...(o.unattended ? { unattended: o.unattended } : {}),
61
78
  };
62
79
  }
63
- if (o.bioFile)
64
- r.bio = readFileSync(o.bioFile, 'utf8').trim();
65
- if (o.personaFile)
66
- r.persona = readFileSync(o.personaFile, 'utf8').trim();
80
+ const profile = profileValues(o);
81
+ if (profile.bio)
82
+ r.bio = profile.bio;
83
+ if (profile.persona)
84
+ r.persona = profile.persona;
67
85
  if (o.isolationFile)
68
86
  r.isolation = readIsolationFile(o.isolationFile);
87
+ if (o.monitorConfig)
88
+ r.monitor = { ...o.monitorConfig };
69
89
  return r;
70
90
  }
71
91
  /**
@@ -93,7 +113,7 @@ export function readIsolationFile(path) {
93
113
  throw new Error(`--isolation-file ${path}: ${problems.join('; ')}`);
94
114
  return cfg;
95
115
  }
96
- function validateSpawnOpts(o) {
116
+ export function validateSpawnOpts(o) {
97
117
  if (o.mission !== undefined && o.missionFile)
98
118
  throw new Error('--mission and --mission-file are mutually exclusive');
99
119
  if (o.session && !['tmux', 'acp'].includes(o.session))
@@ -104,6 +124,17 @@ function validateSpawnOpts(o) {
104
124
  throw new Error(`invalid --filesystem '${o.filesystem}'; allowed: read-only, workspace, unrestricted`);
105
125
  if (o.unattended && !['deny', 'wait'].includes(o.unattended))
106
126
  throw new Error(`invalid --unattended '${o.unattended}'; allowed: deny, wait`);
127
+ if (!/^[A-Za-z0-9_-]+$/.test(o.name))
128
+ throw new Error(`invalid role name '${o.name}'`);
129
+ if (o.identity && !/^[A-Za-z0-9_-]+$/.test(o.identity))
130
+ throw new Error(`invalid identity name '${o.identity}'`);
131
+ if (o.model && (o.model.length > 128 || /[\0-\x1f\x7f]/.test(o.model)))
132
+ throw new Error('model must be printable and at most 128 characters');
133
+ if (o.monitorConfig) {
134
+ const problems = validateMonitorConfig(o.monitorConfig);
135
+ if (problems.length)
136
+ throw new Error(problems.join('; '));
137
+ }
107
138
  }
108
139
  /** Read mission text without trimming or newline rewriting. */
109
140
  export function readMissionFile(path) {
@@ -146,23 +177,29 @@ export function spawnDryRun(o) {
146
177
  readMissionFile(o.missionFile);
147
178
  assertNameFree(o);
148
179
  const cfg = loadConfig(o.configPath);
149
- const raw = roleFromOpts(o, cfg.defaults.harness);
180
+ const raw = buildRoleConfig(o, cfg.defaults.harness);
150
181
  const harnessOptions = {
151
182
  ...(cfg.defaults.harness_options ?? {}),
152
183
  ...(raw.harness_options ?? {}),
153
184
  };
185
+ const harness = raw.harness ?? cfg.defaults.harness ?? 'claude-code';
186
+ const defaultHarness = cfg.defaults.harness ?? 'claude-code';
187
+ const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
188
+ const model = resolveRoleModel(raw.model, raw.harness, cfg.defaults);
154
189
  const resolvedRole = {
155
190
  ...raw,
156
191
  name: o.name,
157
192
  sourceFile: o.temp ? '(temp dry-run)' : join(fleetDDir(), `${o.name}.yaml`),
158
- harness: raw.harness ?? cfg.defaults.harness ?? 'claude-code',
193
+ harness,
159
194
  session: raw.session ?? cfg.defaults.session ?? 'tmux',
160
195
  session_options: raw.session_options,
161
196
  permissions: resolvePermissions(cfg.defaults.permissions, raw.permissions),
162
197
  permissionsDeclared: raw.permissions !== undefined || cfg.defaults.permissions !== undefined,
163
198
  identity: effectiveIdentity(o),
164
- model: raw.model ?? cfg.defaults.model,
165
- model_chain: resolveModelChain(raw.model ?? cfg.defaults.model, raw.model_chain ?? cfg.defaults.model_chain),
199
+ model,
200
+ model_chain: resolveModelChain(model, raw.model_chain ?? (inheritsModelDefaults
201
+ ? cfg.defaults.model_chain
202
+ : undefined)),
166
203
  harness_options: Object.keys(harnessOptions).length ? harnessOptions : undefined,
167
204
  isolation: raw.isolation ?? cfg.defaults.isolation,
168
205
  monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
@@ -199,6 +236,8 @@ export function spawnDryRun(o) {
199
236
  */
200
237
  function provenanceSettings(o, defaults) {
201
238
  const perms = (defaults.permissions ?? {});
239
+ const explicitModel = typeof o.model === 'string' ? o.model.trim() : undefined;
240
+ const inheritedModel = resolveRoleModel(undefined, o.harness, defaults);
202
241
  return {
203
242
  harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
204
243
  session: provenanceOf(o.session, defaults.session, 'tmux'),
@@ -206,7 +245,11 @@ function provenanceSettings(o, defaults) {
206
245
  ? { value: o.identity, source: 'cli' }
207
246
  : { value: o.name, source: 'built-in' }, // defaults to the role name
208
247
  cwd: provenanceOf(o.cwd, undefined, undefined),
209
- model: provenanceOf(o.model?.trim(), defaults.model, undefined),
248
+ model: o.model === null
249
+ ? { value: undefined, source: 'cli' }
250
+ : explicitModel
251
+ ? { value: explicitModel, source: 'cli' }
252
+ : { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' },
210
253
  coordinator: provenanceOf(o.coordinator, undefined, undefined),
211
254
  approval: provenanceOf(o.approval, perms.approval, 'ask'),
212
255
  filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
@@ -214,6 +257,7 @@ function provenanceSettings(o, defaults) {
214
257
  isolation: o.isolationFile
215
258
  ? { value: 'declared via --isolation-file', source: 'cli' }
216
259
  : { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
260
+ monitor: provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' }),
217
261
  };
218
262
  }
219
263
  /** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
@@ -225,13 +269,17 @@ export async function spawnPermanent(o, deps, creation = {}) {
225
269
  readMissionFile(o.missionFile); // fail before reserving
226
270
  // Name AND identity reserved together, before anything is written or started
227
271
  // (6.4). A loser of the race creates no config, no state, no service.
272
+ creation.onStage?.('reserving');
228
273
  return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
229
274
  assertNameFree(o);
230
275
  const cfg = loadConfig(o.configPath);
231
276
  // Establish the identity BEFORE the service is enabled (7.3), and record
232
277
  // what was actually guaranteed so the briefing can say something true.
233
- const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
234
- persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
278
+ creation.onStage?.('checking_identity');
279
+ const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
280
+ creation.onStage?.('checking_identity', {
281
+ result: guarantee.evidence, guarantee: guarantee.state,
282
+ });
235
283
  if (guarantee.state === 'created')
236
284
  // We minted it; a failed creation must not leave an orphan identity
237
285
  // behind. Only ever removes an identity THIS transaction created.
@@ -242,9 +290,10 @@ export async function spawnPermanent(o, deps, creation = {}) {
242
290
  },
243
291
  });
244
292
  mkdirSync(fleetDDir(), { recursive: true });
293
+ creation.onStage?.('writing_role');
245
294
  const file = join(fleetDDir(), `${o.name}.yaml`);
246
295
  writeRoleFile(tx, file, stringify({
247
- roles: { [o.name]: roleFromOpts(o, cfg.defaults.harness) },
296
+ roles: { [o.name]: buildRoleConfig(o, cfg.defaults.harness) },
248
297
  }));
249
298
  // `up` materialises the state dir and registers the service. Journal the
250
299
  // dir before it exists so a failure leaves the name genuinely reusable
@@ -272,9 +321,11 @@ export async function spawnPermanent(o, deps, creation = {}) {
272
321
  const provenance = buildProvenance({
273
322
  role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
274
323
  settings: provenanceSettings(o, cfg.defaults),
324
+ surface: o.surface, creationActionId: o.creationActionId,
275
325
  });
276
326
  mkdirSync(agentDir(o.name), { recursive: true });
277
327
  writeProvenance(agentDir(o.name), provenance);
328
+ creation.onStage?.('registering_supervisor');
278
329
  await up(loadConfig(o.configPath), [o.name], { ...deps, onInstalled: outcome => registered.push(outcome.role) }, o.configPath, guarantee.state);
279
330
  lastProvenance = provenance;
280
331
  return file;
@@ -298,29 +349,45 @@ export async function spawnTemp(o, binPath, launch = detachedSupervisor, creatio
298
349
  readMissionFile(o.missionFile); // fail before reserving
299
350
  // Temporary roles go through the SAME reservation boundary as permanent ones
300
351
  // (6.4): a temp agent competes for the same names.
352
+ creation.onStage?.('reserving');
301
353
  return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
354
+ creation.onStage?.('checking_identity');
302
355
  assertNameFree(o);
303
- const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
304
- persona: o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined }, creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
305
- return spawnTempInner(o, binPath, launch, tx, guarantee);
356
+ const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? daemonIdentityProvisioner(), creation.log);
357
+ creation.onStage?.('checking_identity', {
358
+ result: guarantee.evidence, guarantee: guarantee.state,
359
+ });
360
+ if (guarantee.state === 'created')
361
+ tx.record({
362
+ stage: `ours identity ${effectiveIdentity(o)}`,
363
+ undo: async () => {
364
+ await creation.identityProvisioner?.remove?.(effectiveIdentity(o));
365
+ },
366
+ });
367
+ return spawnTempInner(o, binPath, launch, tx, guarantee, creation.onStage);
306
368
  }, creation);
307
369
  }
308
- async function spawnTempInner(o, binPath, launch, tx, guarantee) {
370
+ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
309
371
  const cfg = loadConfig(o.configPath);
310
372
  const defaultHarness = cfg.defaults.harness;
311
- const fromOpts = roleFromOpts(o, defaultHarness);
373
+ const fromOpts = buildRoleConfig(o, defaultHarness);
312
374
  const mergedHarnessOptions = {
313
375
  ...(cfg.defaults.harness_options ?? {}),
314
376
  ...(fromOpts.harness_options ?? {}),
315
377
  };
378
+ const harness = o.harness ?? defaultHarness ?? 'claude-code';
379
+ const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
380
+ const model = resolveRoleModel(o.model, o.harness, cfg.defaults);
316
381
  const role = {
317
382
  ...fromOpts, // includes `isolation` when --isolation-file was given
318
383
  name: o.name,
319
- harness: o.harness ?? defaultHarness ?? 'claude-code',
384
+ harness,
320
385
  session: o.session ?? cfg.defaults.session ?? 'tmux',
321
386
  identity: o.identity ?? o.name,
322
- model: o.model?.trim() || cfg.defaults.model,
323
- model_chain: resolveModelChain(o.model?.trim() || cfg.defaults.model, fromOpts.model_chain ?? cfg.defaults.model_chain),
387
+ model,
388
+ model_chain: resolveModelChain(model, fromOpts.model_chain ?? (inheritsModelDefaults
389
+ ? cfg.defaults.model_chain
390
+ : undefined)),
324
391
  harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
325
392
  permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
326
393
  permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
@@ -337,10 +404,12 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee) {
337
404
  };
338
405
  if (role.auth_proxy && role.harness !== 'claude-code')
339
406
  throw new Error('auth_proxy is supported only by claude-code');
407
+ onStage?.('writing_role');
340
408
  const dir = applyRole(role, { temp: true, identityGuarantee: guarantee.state });
341
409
  const provenance = buildProvenance({
342
410
  role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
343
411
  settings: provenanceSettings(o, cfg.defaults),
412
+ surface: o.surface, creationActionId: o.creationActionId,
344
413
  });
345
414
  writeProvenance(dir, provenance);
346
415
  lastProvenance = provenance;
@@ -356,6 +425,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee) {
356
425
  // agent itself; a supervisor sharing that session name would SIGHUP its own
357
426
  // process before the agent ever launches. Detaching mirrors how systemd hosts
358
427
  // the supervisor for permanent roles, leaving runOnce to own the <name> session.
428
+ onStage?.('starting_temp');
359
429
  launch(binPath, ['_run-temp', o.name], dir);
360
430
  return dir;
361
431
  }
@@ -177,6 +177,23 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
177
177
  return { state: 'stopped', detail: `not loaded (${labelFor(name)})` };
178
178
  return { state: 'unknown', detail: job.failure ?? `launchctl print ${labelFor(name)} failed` };
179
179
  },
180
+ async inspect(name) {
181
+ const job = await printJob(name);
182
+ if (job.loaded)
183
+ return {
184
+ backend: 'launchd', state: 'running',
185
+ nativeState: job.state, detail: job.state ? `loaded (state = ${job.state})` : 'loaded',
186
+ };
187
+ if (job.notFound)
188
+ return {
189
+ backend: 'launchd', state: 'stopped',
190
+ nativeState: 'not-loaded', detail: `not loaded (${labelFor(name)})`,
191
+ };
192
+ return {
193
+ backend: 'launchd', state: 'unknown',
194
+ detail: job.failure ?? `launchctl print ${labelFor(name)} failed`,
195
+ };
196
+ },
180
197
  async uninstall(name) {
181
198
  const existed = existsSync(plistPath(name));
182
199
  await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // idempotent
@@ -31,6 +31,23 @@ export function makeNoneBackend(exec = realExec) {
31
31
  return { state: 'stopped', detail: `no tmux session '${name}'` };
32
32
  return { state: 'unknown', detail: `tmux has-session '${name}' failed (${r.code}): ${r.stderr.trim() || 'no output'}` };
33
33
  },
34
+ async inspect(name) {
35
+ const r = await exec('tmux', tmuxArgs(name, ['has-session', '-t', name]));
36
+ if (r.code === 0)
37
+ return {
38
+ backend: 'none', state: 'running',
39
+ nativeState: 'tmux-present', detail: `tmux session '${name}' exists`,
40
+ };
41
+ if (r.code === 1)
42
+ return {
43
+ backend: 'none', state: 'stopped',
44
+ nativeState: 'tmux-absent', detail: `no tmux session '${name}'`,
45
+ };
46
+ return {
47
+ backend: 'none', state: 'unknown',
48
+ detail: `tmux has-session '${name}' failed (${r.code}): ${r.stderr.trim() || 'no output'}`,
49
+ };
50
+ },
34
51
  async uninstall(name) {
35
52
  const killed = await tmux.kill(name); // idempotent
36
53
  return killed
@@ -148,6 +148,10 @@ WantedBy=default.target
148
148
  return r.stdout || r.stderr;
149
149
  },
150
150
  liveness(name) { return probeLiveness(ctl, name); },
151
+ async inspect(name) {
152
+ const live = await probeLiveness(ctl, name);
153
+ return { backend: 'systemd', ...live, nativeState: live.detail.split(/\s/)[0] };
154
+ },
151
155
  async uninstall(name) {
152
156
  const before = await ctl('is-enabled', unitFor(name));
153
157
  const wasEnabled = before.stdout.trim() === 'enabled';
@@ -19,6 +19,10 @@ export interface UninstallOutcome {
19
19
  removed: boolean;
20
20
  detail: string;
21
21
  }
22
+ export interface SupervisorInspection extends Liveness {
23
+ backend: 'systemd' | 'launchd' | 'none';
24
+ nativeState?: string;
25
+ }
22
26
  export interface SupervisorBackend {
23
27
  id: 'systemd' | 'launchd' | 'none';
24
28
  /** One-time host setup (unit template / dirs / linger). Returns human-readable messages. */
@@ -40,6 +44,8 @@ export interface SupervisorBackend {
40
44
  * probe is `unknown` with the failure in `detail`.
41
45
  */
42
46
  liveness(name: string): Promise<Liveness>;
47
+ /** Structured machine-derived status for application-service consumers. */
48
+ inspect?(name: string): Promise<SupervisorInspection>;
43
49
  /** Remove the registration. Idempotent; reports whether anything was there. */
44
50
  uninstall(name: string): Promise<UninstallOutcome>;
45
51
  /** Command the CLI execs (stdio inherited) to show logs. */
package/dist/tmux.d.ts CHANGED
@@ -33,6 +33,8 @@ export declare class Tmux {
33
33
  */
34
34
  kill(name: string): Promise<boolean>;
35
35
  capture(name: string, lines?: number): Promise<string>;
36
+ /** Bounded ANSI/history seed for the browser terminal projection. */
37
+ captureHistory(name: string, lines?: number): Promise<string>;
36
38
  panePid(name: string): Promise<number | null>;
37
39
  /**
38
40
  * List the live sessions among `names`, asking each server in turn.
package/dist/tmux.js CHANGED
@@ -49,6 +49,14 @@ export class Tmux {
49
49
  const all = r.stdout.replace(/\n+$/, '').split('\n');
50
50
  return all.slice(-lines).join('\n');
51
51
  }
52
+ /** Bounded ANSI/history seed for the browser terminal projection. */
53
+ async captureHistory(name, lines = 5_000) {
54
+ const bounded = Math.min(Math.max(Math.trunc(lines), 1), 20_000);
55
+ const r = await this.exec('tmux', tmuxArgs(name, ['capture-pane', '-t', name, '-p', '-e', '-J', '-S', `-${bounded}`]));
56
+ if (r.code !== 0)
57
+ throw new Error(`tmux capture-pane '${name}' failed: ${r.stderr.trim()}`);
58
+ return Buffer.from(r.stdout).subarray(0, 4 * 1024 * 1024).toString();
59
+ }
52
60
  async panePid(name) {
53
61
  const r = await this.exec('tmux', tmuxArgs(name, ['list-panes', '-t', name, '-F', '#{pane_pid}']));
54
62
  if (r.code !== 0)
@@ -0,0 +1,34 @@
1
+ import type { WatchdogReport, WatchdogRoleStatus } from './report.js';
2
+ import type { WatchManifest } from './briefing.js';
3
+ /**
4
+ * Severity ordering, lowest to highest (must match the briefing contract's alert-rules
5
+ * sentence verbatim, src/watchdog/briefing.ts): healthy = idle (0) < unknown (1) < stale (2)
6
+ * < blocked = unreachable (3) < off_briefing (4).
7
+ */
8
+ export declare const WATCHDOG_STATUS_RANK: Record<WatchdogRoleStatus, number>;
9
+ export interface OpenFinding {
10
+ role: string;
11
+ status: WatchdogRoleStatus;
12
+ since: string;
13
+ lastAlertedAt: string | null;
14
+ }
15
+ export interface AlertLedger {
16
+ version: 1;
17
+ open: Record<string, OpenFinding>;
18
+ heldDownAlerted: boolean;
19
+ }
20
+ /** Missing or corrupt ledger file yields a clean empty ledger rather than throwing. */
21
+ export declare function readLedger(name: string): AlertLedger;
22
+ export declare function writeLedger(name: string, l: AlertLedger): void;
23
+ /**
24
+ * Reconcile rules (spec §5): an `error`-status report carries no role evidence, so the ledger
25
+ * is returned unchanged. For each finding with rank > 0: an existing open entry keeps its
26
+ * `since` and updates `status` on escalation/de-escalation; a new one opens with `since = now`.
27
+ * Any role reported `healthy`/`idle` closes (deletes) its open entry. Every role named in
28
+ * `report.alerts` gets `lastAlertedAt = now`. Roles absent from the report keep their entries —
29
+ * a watchdog with a narrowed `watch:` set doesn't silently resolve findings it never inspected.
30
+ * Never mutates `l` — always returns a new ledger object.
31
+ */
32
+ export declare function reconcileLedger(l: AlertLedger, report: WatchdogReport, now: Date): AlertLedger;
33
+ /** Ledger's open findings + cooldown, in the shape watch.json's `digest` field carries (Task 7 writes it, run reads it back). */
34
+ export declare function computeDigest(l: AlertLedger, cooldownMs: number, now: Date): WatchManifest['digest'];
@@ -0,0 +1,78 @@
1
+ import { chmodSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { watchdogDir } from './store.js';
4
+ /**
5
+ * Severity ordering, lowest to highest (must match the briefing contract's alert-rules
6
+ * sentence verbatim, src/watchdog/briefing.ts): healthy = idle (0) < unknown (1) < stale (2)
7
+ * < blocked = unreachable (3) < off_briefing (4).
8
+ */
9
+ export const WATCHDOG_STATUS_RANK = {
10
+ healthy: 0, idle: 0, unknown: 1, stale: 2, blocked: 3, unreachable: 3, off_briefing: 4,
11
+ };
12
+ function emptyLedger() {
13
+ return { version: 1, open: {}, heldDownAlerted: false };
14
+ }
15
+ function ledgerPath(name) {
16
+ return join(watchdogDir(name), 'alerts.json');
17
+ }
18
+ /** Missing or corrupt ledger file yields a clean empty ledger rather than throwing. */
19
+ export function readLedger(name) {
20
+ try {
21
+ return JSON.parse(readFileSync(ledgerPath(name), 'utf8'));
22
+ }
23
+ catch {
24
+ return emptyLedger();
25
+ }
26
+ }
27
+ export function writeLedger(name, l) {
28
+ const path = ledgerPath(name);
29
+ writeFileSync(path, JSON.stringify(l, null, 2) + '\n', { mode: 0o600 });
30
+ chmodSync(path, 0o600);
31
+ }
32
+ /**
33
+ * Reconcile rules (spec §5): an `error`-status report carries no role evidence, so the ledger
34
+ * is returned unchanged. For each finding with rank > 0: an existing open entry keeps its
35
+ * `since` and updates `status` on escalation/de-escalation; a new one opens with `since = now`.
36
+ * Any role reported `healthy`/`idle` closes (deletes) its open entry. Every role named in
37
+ * `report.alerts` gets `lastAlertedAt = now`. Roles absent from the report keep their entries —
38
+ * a watchdog with a narrowed `watch:` set doesn't silently resolve findings it never inspected.
39
+ * Never mutates `l` — always returns a new ledger object.
40
+ */
41
+ export function reconcileLedger(l, report, now) {
42
+ if (report.status === 'error')
43
+ return l;
44
+ const open = { ...l.open };
45
+ const nowIso = now.toISOString();
46
+ for (const finding of report.roles) {
47
+ const rank = WATCHDOG_STATUS_RANK[finding.status];
48
+ if (rank > 0) {
49
+ const existing = open[finding.role];
50
+ open[finding.role] = {
51
+ role: finding.role,
52
+ status: finding.status,
53
+ since: existing ? existing.since : nowIso,
54
+ lastAlertedAt: existing ? existing.lastAlertedAt : null,
55
+ };
56
+ }
57
+ else {
58
+ delete open[finding.role];
59
+ }
60
+ }
61
+ for (const alert of report.alerts) {
62
+ if (open[alert.role])
63
+ open[alert.role] = { ...open[alert.role], lastAlertedAt: nowIso };
64
+ }
65
+ return { ...l, open };
66
+ }
67
+ /** Ledger's open findings + cooldown, in the shape watch.json's `digest` field carries (Task 7 writes it, run reads it back). */
68
+ export function computeDigest(l, cooldownMs, now) {
69
+ return {
70
+ cooldown_ms: cooldownMs,
71
+ open: Object.values(l.open).map(f => ({
72
+ role: f.role,
73
+ status: f.status,
74
+ since: f.since,
75
+ realert_after: f.lastAlertedAt ? new Date(new Date(f.lastAlertedAt).getTime() + cooldownMs).toISOString() : null,
76
+ })),
77
+ };
78
+ }
@@ -0,0 +1,65 @@
1
+ import type { ResolvedWatchdog } from './config.js';
2
+ import type { BriefingVocab } from '../harness/types.js';
3
+ /** One entry in `watch.json`'s `roles` array — where to find a watched role's state. */
4
+ export interface WatchManifestRole {
5
+ name: string;
6
+ stateDir: string;
7
+ }
8
+ /**
9
+ * The fixed run manifest a watchdog run reads at `manifestPath` (7.3 §3, Task 7 writes it,
10
+ * Task 12+ digest reads it back). It carries everything the run needs to identify itself in
11
+ * report.json (`watchdog`, `run_id`, `started_at`) plus the suppression digest that makes
12
+ * alerting idempotent across runs.
13
+ */
14
+ export interface WatchManifest {
15
+ watchdog: string;
16
+ run_id: string;
17
+ coordinator: string;
18
+ started_at: string;
19
+ roles: WatchManifestRole[];
20
+ digest: {
21
+ cooldown_ms: number;
22
+ open: Array<{
23
+ role: string;
24
+ status: string;
25
+ since: string;
26
+ realert_after: string | null;
27
+ }>;
28
+ };
29
+ }
30
+ export interface WatchdogBriefingOpts {
31
+ wd: ResolvedWatchdog;
32
+ manifestPath: string;
33
+ reportPath: string;
34
+ vocabulary: BriefingVocab;
35
+ /** What spawn actually established about the watchdog's ours identity (7.3, decision 3). */
36
+ identityGuarantee: 'verified' | 'created' | 'unverified';
37
+ /** Raw prompt_file content, appended as extra focus — never a replacement (owner decision 1). */
38
+ promptFocus?: string;
39
+ }
40
+ /**
41
+ * Render a watchdog run's fixed contract (briefing.md-equivalent for a one-shot clean-context
42
+ * run): bind, observe-only rules, procedure, status vocabulary, evidence rules, alert rules,
43
+ * report schema, and — if the watchdog configures one — an appended prompt_file focus.
44
+ *
45
+ * The contract is fixed for every watchdog (spec §5: "The schema is fixed for every watchdog,
46
+ * including ones with a prompt_file; the override adds focus, never fields"), so every section
47
+ * below is unconditional except the identity-guarantee wording and the trailing focus append.
48
+ */
49
+ export declare function generateWatchdogBriefing(opts: WatchdogBriefingOpts): string;
50
+ export interface NotifierBriefingOpts {
51
+ wd: ResolvedWatchdog;
52
+ vocabulary: BriefingVocab;
53
+ /** What spawn actually established about the watchdog's ours identity (7.3, decision 3). */
54
+ identityGuarantee: 'verified' | 'created' | 'unverified';
55
+ /** The exact message to relay — the scheduler composed this, the notifier only delivers it. */
56
+ text: string;
57
+ }
58
+ /**
59
+ * Render a scheduler-alert notifier run's entire contract: a minimal one-shot agent whose sole
60
+ * job is to bind its identity, send one exact message to the coordinator, and write `sent.json`
61
+ * as its completion sentinel. Used only for scheduler-level alerts (e.g. held-down) — the fleet
62
+ * process itself cannot send ours messages (deviation 4), so this is how it delegates the send.
63
+ * Unlike `generateWatchdogBriefing`, there is no report.json, no manifest, and no inspection.
64
+ */
65
+ export declare function generateNotifierBriefing(opts: NotifierBriefingOpts): string;