@ours.network/fleet 0.18.0-nightly.6 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +81 -43
  2. package/dist/application/fleet-query-service.js +12 -0
  3. package/dist/application/role-creation-service.js +3 -1
  4. package/dist/application/types.d.ts +11 -0
  5. package/dist/briefing.js +9 -2
  6. package/dist/build-info.json +5 -5
  7. package/dist/cli.js +37 -7
  8. package/dist/config.d.ts +6 -3
  9. package/dist/config.js +28 -14
  10. package/dist/creation.d.ts +14 -15
  11. package/dist/creation.js +19 -13
  12. package/dist/docs.d.ts +1 -1
  13. package/dist/docs.js +92 -33
  14. package/dist/doctor.d.ts +1 -5
  15. package/dist/doctor.js +11 -18
  16. package/dist/fleet-proxy.d.ts +5 -0
  17. package/dist/harness/acp-agent.js +11 -6
  18. package/dist/harness/claude-code.js +200 -11
  19. package/dist/harness/codex.d.ts +4 -1
  20. package/dist/harness/codex.js +70 -12
  21. package/dist/harness/types.d.ts +54 -4
  22. package/dist/loops/manager.d.ts +30 -1
  23. package/dist/loops/manager.js +69 -6
  24. package/dist/loops/state.d.ts +18 -0
  25. package/dist/loops/state.js +4 -0
  26. package/dist/model-env.d.ts +71 -0
  27. package/dist/model-env.js +106 -0
  28. package/dist/monitor.js +1 -1
  29. package/dist/ops.js +1 -1
  30. package/dist/owner-channel/attachments.d.ts +2 -25
  31. package/dist/owner-channel/attachments.js +5 -61
  32. package/dist/owner-channel/channel.d.ts +30 -29
  33. package/dist/owner-channel/channel.js +291 -291
  34. package/dist/owner-channel/mcp.d.ts +24 -0
  35. package/dist/owner-channel/mcp.js +145 -0
  36. package/dist/owner-channel/notices.d.ts +7 -0
  37. package/dist/owner-channel/notices.js +9 -0
  38. package/dist/runner.d.ts +48 -0
  39. package/dist/runner.js +237 -85
  40. package/dist/session/acp.d.ts +104 -0
  41. package/dist/session/acp.js +213 -10
  42. package/dist/session/activity.d.ts +31 -0
  43. package/dist/session/activity.js +48 -0
  44. package/dist/session/conversation-normalizer.d.ts +6 -0
  45. package/dist/session/conversation-normalizer.js +153 -10
  46. package/dist/session/conversation-types.d.ts +23 -4
  47. package/dist/session/types.d.ts +35 -0
  48. package/dist/spawn.js +29 -17
  49. package/dist/supervisor/systemd.js +2 -29
  50. package/dist/watchdog/briefing.js +7 -0
  51. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  52. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  53. package/dist/web-app/index.html +1 -1
  54. package/dist/worklog.d.ts +7 -1
  55. package/dist/worklog.js +191 -39
  56. package/package.json +1 -3
  57. package/dist/owner-channel/message-recovery.d.ts +0 -25
  58. package/dist/owner-channel/message-recovery.js +0 -114
  59. package/dist/owner-channel/ours-client.d.ts +0 -148
  60. package/dist/owner-channel/ours-client.js +0 -231
@@ -39,7 +39,7 @@ export class ScheduledLoopManager {
39
39
  }
40
40
  start() {
41
41
  if (!this.store.fresh)
42
- this.skipRestartMisses();
42
+ this.skipRestartBacklog();
43
43
  this.schedule();
44
44
  }
45
45
  async stop() {
@@ -143,10 +143,15 @@ export class ScheduledLoopManager {
143
143
  async attempt(definition, state, scheduledAt) {
144
144
  const runId = `sl_${randomUUID()}`;
145
145
  const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
146
- const prompt = this.envelope(definition, runId, scheduledAt);
146
+ // The gap is read here and cleared only if the turn is actually admitted:
147
+ // an attempt that ends `skipped_busy` or `unavailable` reported it to
148
+ // nobody, so it has to still be there for the attempt that succeeds.
149
+ const gap = state.missedGap;
150
+ const prompt = this.envelope(definition, runId, scheduledAt, gap);
147
151
  let claimed = false;
148
152
  const result = await this.arbiter.tryScheduled(prompt, origin, () => {
149
153
  claimed = true;
154
+ state.missedGap = null;
150
155
  state.activeRunId = runId;
151
156
  state.lastRunId = runId;
152
157
  state.lastStartedAt = new Date(this.deps.now()).toISOString();
@@ -265,7 +270,15 @@ export class ScheduledLoopManager {
265
270
  state.nextScheduledAt = new Date(next).toISOString();
266
271
  state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
267
272
  }
273
+ /**
274
+ * Coalesce a backlog into one skip. The counters alone say how many
275
+ * occurrences were lost but never when or for how long, so the window is
276
+ * recorded too and carried on the state until a run is actually told about it
277
+ * — a dropped pass has to stay visible to the next one, not just to whoever
278
+ * was reading the log at the time.
279
+ */
268
280
  skipMissed(definition, state, now) {
281
+ const from = state.nextScheduledAt;
269
282
  let missed = 0;
270
283
  while (Date.parse(state.nextDueAt) <= now) {
271
284
  this.advance(definition, state);
@@ -275,14 +288,43 @@ export class ScheduledLoopManager {
275
288
  state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
276
289
  state.lastOutcome = 'skipped_missed';
277
290
  state.lastFinishedAt = new Date(now).toISOString();
291
+ // Successive outages before any run lands merge into one gap: the earliest
292
+ // start wins, so the window always spans the whole silence.
293
+ const previous = state.missedGap;
294
+ state.missedGap = {
295
+ count: increment(previous?.count ?? 0, missed),
296
+ fromAt: previous?.fromAt ?? from,
297
+ throughAt: state.lastScheduledAt ?? from,
298
+ detectedAt: new Date(now).toISOString(),
299
+ };
278
300
  this.store.persist();
279
- this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`);
301
+ this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed} `
302
+ + `gap=${from}..${state.missedGap.throughAt} `
303
+ + `unreported=${state.missedGap.count}`);
280
304
  }
281
- skipRestartMisses() {
305
+ /**
306
+ * Restart is not, by itself, a reason to lose an occurrence a running manager
307
+ * would still have run. `poll` tolerates lateness up to one full interval and
308
+ * runs the tick late; this path used to drop anything already due however
309
+ * recently, so a role restarted seconds after its own tick came due lost it
310
+ * outright. For an oversight role that is precisely the pass which would have
311
+ * recorded why it restarted, so the failure erased its own witness.
312
+ *
313
+ * The tolerance is the only thing shared with `poll`. A backlog at least one
314
+ * interval deep is still coalesced into a single skip and never replayed —
315
+ * after a long outage exactly one occurrence survives, and `schedule` then
316
+ * arms it through the ordinary path rather than firing a burst here.
317
+ *
318
+ * Running the survivor late cannot outpace the configured cadence: `advance`
319
+ * moves the cursor by exactly one `intervalMs` per occurrence from the nominal
320
+ * time, so a loop that keeps restarting still runs at most once per interval.
321
+ */
322
+ skipRestartBacklog() {
282
323
  const now = this.deps.now();
283
324
  for (const definition of this.definitions.values()) {
284
325
  const state = this.store.state.loops[definition.name];
285
- if (definition.enabled && !state.operatorDisabled && Date.parse(state.nextDueAt) <= now)
326
+ if (definition.enabled && !state.operatorDisabled
327
+ && now >= Date.parse(state.nextDueAt) + definition.intervalMs)
286
328
  this.skipMissed(definition, state, now);
287
329
  }
288
330
  }
@@ -340,17 +382,38 @@ export class ScheduledLoopManager {
340
382
  this.deps.clearTimer(this.timer);
341
383
  this.arm(backoffMs(this.pollFailures));
342
384
  }
343
- envelope(definition, runId, scheduledAt) {
385
+ /**
386
+ * The envelope is the only channel a scheduled pass has for learning about
387
+ * the passes that did not happen. A gap stated here is what lets an oversight
388
+ * role report its own outage instead of resuming as if nothing was missed.
389
+ */
390
+ envelope(definition, runId, scheduledAt, gap) {
391
+ const lateBy = Math.max(0, this.deps.now() - scheduledAt);
344
392
  return [
345
393
  '[fleet-loop]',
346
394
  `loop: ${definition.name}`,
347
395
  `run: ${runId}`,
348
396
  `scheduled_at: ${new Date(scheduledAt).toISOString()}`,
397
+ ...(lateBy > 0 ? [`started_late_by_ms: ${lateBy}`] : []),
398
+ ...(gap ? [
399
+ `missed_occurrences: ${gap.count}`,
400
+ `missed_window: ${gap.fromAt}..${gap.throughAt}`,
401
+ `missed_gap_ms: ${Math.max(0, Date.parse(gap.detectedAt) - Date.parse(gap.fromAt))}`,
402
+ ] : []),
349
403
  'origin: local-trusted-config',
350
404
  '',
351
405
  'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
352
406
  'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
353
407
  'configured policy and an existing authenticated proactive-report route authorize a material report.',
408
+ // Same single route as the owner-request prompt, and for the same reason.
409
+ 'To send a file, call ours `send_file` with the recipient and the path — to your owner-channel',
410
+ 'identity if this role has one, otherwise directly to the contact who should receive it.',
411
+ 'A file written anywhere else is not delivered and nothing will report that it was not.',
412
+ ...(gap ? ['',
413
+ 'This loop did not run for the window above: those occurrences were coalesced away while the role',
414
+ 'was unavailable, and this pass is the first since. Treat the gap as part of what you are reporting',
415
+ 'on — it is the record of your own outage, and no later pass will be told about it.',
416
+ ] : []),
354
417
  '',
355
418
  definition.prompt,
356
419
  ].join('\n');
@@ -8,11 +8,29 @@ export interface LoopCounts {
8
8
  skippedBusy: number;
9
9
  skippedMissed: number;
10
10
  }
11
+ /**
12
+ * A coalesced run of occurrences that were never submitted, held until a run
13
+ * actually starts and can be told about it. Without it a dropped occurrence
14
+ * survives only as a counter, which says how many were lost but never when or
15
+ * for how long — and an oversight role cannot report an outage it cannot date.
16
+ */
17
+ export interface LoopMissedGap {
18
+ /** Occurrences coalesced away, summed across every skip since the last run. */
19
+ count: number;
20
+ /** Nominal time of the earliest occurrence in the gap. */
21
+ fromAt: string;
22
+ /** Nominal time of the latest occurrence in the gap. */
23
+ throughAt: string;
24
+ /** When the manager noticed — the end of the outage, not of the last skip. */
25
+ detectedAt: string;
26
+ }
11
27
  export interface LoopRuntimeState {
12
28
  definitionHash: string;
13
29
  promptHash: string;
14
30
  enabled: boolean;
15
31
  operatorDisabled: boolean;
32
+ /** Unreported gap, cleared by the first run that carries it. */
33
+ missedGap: LoopMissedGap | null;
16
34
  nextScheduledAt: string;
17
35
  nextDueAt: string;
18
36
  lastScheduledAt: string | null;
@@ -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.js CHANGED
@@ -42,7 +42,7 @@ class AuthError extends Error {
42
42
  }
43
43
  /** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
44
44
  const daemonConfigPath = (env) => env.OURS_CONFIG ?? join(homedir(), '.ours', 'config.json');
45
- /** Preserve the daemon's legacy env integer semantics: parseInt, invalid → absent. */
45
+ /** Match ours-mcp's env integer semantics: parseInt, invalid → absent. */
46
46
  function envInt(env, name) {
47
47
  const raw = env[name];
48
48
  if (raw === undefined)
package/dist/ops.js CHANGED
@@ -16,7 +16,7 @@ import { realExec } from './exec.js';
16
16
  /** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
17
17
  export function applyRole(role, opts = {}) {
18
18
  const adapter = getAdapter(role.harness);
19
- const errs = adapter.validateOptions(role.harness_options);
19
+ const errs = adapter.validateOptions(role.harness_options, role);
20
20
  if (errs.length)
21
21
  throw new Error(`role '${role.name}': ` + errs.map(e => `${e.path}: ${e.message}`).join('; '));
22
22
  const dir = agentDir(role.name, opts.temp === true);
@@ -1,6 +1,4 @@
1
- import { type FileHandle } from 'node:fs/promises';
2
1
  import type { OwnerAttachmentConfig } from '../config.js';
3
- import type { OursIncomingFile, OursRetrievedFiles } from './ours-client.js';
4
2
  export interface AttachmentReplyRef {
5
3
  wire_id: string;
6
4
  sentence?: number;
@@ -44,14 +42,8 @@ export interface AdmittedAttachment {
44
42
  kind: 'file' | 'voice_message';
45
43
  transcription?: Omit<VoiceTranscription, 'audioPath'>;
46
44
  }
47
- /**
48
- * Admit the daemon's file listing. The rows are typed now, but every field is
49
- * still re-validated here: sender CID, wire id, sizes and ids all cross the
50
- * trust boundary and decide routing, so a daemon-side shape change must drop a
51
- * row rather than produce a half-built attachment.
52
- */
53
- export declare function parseIncomingAttachments(raw: OursIncomingFile[] | undefined): IncomingAttachment[];
54
- export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | undefined, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
45
+ export declare function parseIncomingAttachments(raw: unknown): IncomingAttachment[];
46
+ export declare function parseRetrievedAttachments(raw: unknown, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
55
47
  export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
56
48
  /**
57
49
  * Managed-agent -> owner egress limits. This intentionally does not consult
@@ -62,21 +54,6 @@ export declare function prepareAttachmentDirectory(root: string, requestId: stri
62
54
  export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
63
55
  mimePolicy?: 'strict' | 'report-only';
64
56
  }): Promise<AdmittedAttachment[]>;
65
- /** Injectable short-write seam, so partial writes are provably handled. */
66
- export interface AttachmentWriteDeps {
67
- write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
68
- }
69
- /**
70
- * Land crash-recovered file bytes inside an already-prepared request directory.
71
- *
72
- * The MCP path handed the daemon a `dest_path` and let its connector write the
73
- * file. Nothing writes on our behalf any more, so this owns both halves of that
74
- * contract: the destination is DERIVED from a validated wire id inside `dir`
75
- * rather than accepted from a caller, and the file is published by link-after-
76
- * fsync, so a crash or a short write can never leave a partial file where the
77
- * admission step would read it as complete.
78
- */
79
- export declare function writeRecoveredAttachment(dir: string, wireId: string, bytes: Uint8Array, deps?: AttachmentWriteDeps): Promise<string>;
80
57
  export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
81
58
  export declare function removeRequestDirectory(path: string): Promise<void>;
82
59
  export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
@@ -6,14 +6,8 @@ import { replaceFileAtomically } from '../atomic-file.js';
6
6
  const WIRE = /^[A-Fa-f0-9]{64}$/;
7
7
  const CID = /^[A-Fa-f0-9]{64}$/;
8
8
  const MAX_PENDING_REQUESTS = 32;
9
- /**
10
- * Admit the daemon's file listing. The rows are typed now, but every field is
11
- * still re-validated here: sender CID, wire id, sizes and ids all cross the
12
- * trust boundary and decide routing, so a daemon-side shape change must drop a
13
- * row rather than produce a half-built attachment.
14
- */
15
9
  export function parseIncomingAttachments(raw) {
16
- const values = raw;
10
+ const values = raw?.files;
17
11
  if (!Array.isArray(values))
18
12
  return [];
19
13
  const out = [];
@@ -46,12 +40,12 @@ export function parseIncomingAttachments(raw) {
46
40
  export function parseRetrievedAttachments(raw, expected, recovered = false) {
47
41
  const values = raw?.files;
48
42
  if (!Array.isArray(values) || values.length !== expected.length)
49
- throw new Error('the ours daemon returned an incomplete selected attachment set');
43
+ throw new Error('ours-mcp returned an incomplete selected attachment set');
50
44
  const byWire = new Map(expected.map(file => [file.wireId, file]));
51
45
  const out = [];
52
46
  for (const value of values) {
53
47
  if (!value || typeof value !== 'object')
54
- throw new Error('the ours daemon returned invalid attachment metadata');
48
+ throw new Error('ours-mcp returned invalid attachment metadata');
55
49
  const file = value;
56
50
  const wireId = String(file.wire_id ?? '');
57
51
  const listed = byWire.get(wireId);
@@ -64,7 +58,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
64
58
  || !Number.isSafeInteger(size) || size !== listed.size || mime !== listed.mime
65
59
  || kind !== listed.kind || !/^[a-f0-9]{64}$/.test(sha256)
66
60
  || typeof file.path !== 'string' || !file.path)
67
- throw new Error('selected attachment provenance or integrity metadata mismatched');
61
+ throw new Error('ours-mcp selected attachment provenance or integrity metadata mismatched');
68
62
  out.push({
69
63
  ...listed, filename: safeField(file.filename, 255), mime, size, path: file.path, sha256, kind,
70
64
  ...(recovered ? {} : parseTranscription(file.transcription, wireId)),
@@ -72,7 +66,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
72
66
  byWire.delete(wireId);
73
67
  }
74
68
  if (byWire.size)
75
- throw new Error('the ours daemon omitted a selected attachment');
69
+ throw new Error('ours-mcp omitted a selected attachment');
76
70
  return out;
77
71
  }
78
72
  function parseTranscription(value, wireId) {
@@ -225,56 +219,6 @@ export async function admitAttachments(files, dir, config, options = {}) {
225
219
  }
226
220
  return admitted;
227
221
  }
228
- /**
229
- * Land crash-recovered file bytes inside an already-prepared request directory.
230
- *
231
- * The MCP path handed the daemon a `dest_path` and let its connector write the
232
- * file. Nothing writes on our behalf any more, so this owns both halves of that
233
- * contract: the destination is DERIVED from a validated wire id inside `dir`
234
- * rather than accepted from a caller, and the file is published by link-after-
235
- * fsync, so a crash or a short write can never leave a partial file where the
236
- * admission step would read it as complete.
237
- */
238
- export async function writeRecoveredAttachment(dir, wireId, bytes, deps = {}) {
239
- if (!WIRE.test(wireId))
240
- throw new Error('recovered attachment wire id is not a 64-hex value');
241
- const dirStat = await lstat(dir);
242
- if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
243
- throw new Error('recovered attachment directory is not a safe directory');
244
- const write = deps.write
245
- ?? ((handle, buffer, offset) => handle.write(buffer, offset, buffer.length - offset)
246
- .then(result => result.bytesWritten));
247
- const finalPath = join(dir, `.recovered-${wireId}-${randomUUID()}`);
248
- const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
249
- const handle = await open(tmp, 'wx', 0o600);
250
- try {
251
- for (let written = 0; written < bytes.length;) {
252
- const advanced = await write(handle, bytes, written);
253
- if (advanced <= 0)
254
- throw new Error(`recovered attachment write made no progress at byte ${written}`);
255
- written += advanced;
256
- }
257
- await handle.sync();
258
- }
259
- catch (error) {
260
- await handle.close().catch(() => undefined);
261
- await rm(tmp, { force: true });
262
- throw error;
263
- }
264
- await handle.close();
265
- // link publishes the finished bytes under a name that never existed in a
266
- // partial state; the temp is only ever removed after it succeeded.
267
- try {
268
- await link(tmp, finalPath);
269
- }
270
- catch (error) {
271
- await rm(tmp, { force: true });
272
- throw error;
273
- }
274
- await rm(tmp, { force: true });
275
- await chmod(finalPath, 0o600);
276
- return finalPath;
277
- }
278
222
  export async function recoveredAttachment(file, path) {
279
223
  const stat = await lstat(path);
280
224
  if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.size)
@@ -1,8 +1,10 @@
1
+ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
1
2
  import { type OwnerChannelConfig } from '../config.js';
3
+ import { type FetchLike } from '../monitor.js';
2
4
  import { type SessionHandle } from '../session/types.js';
3
5
  import { type OwnerFleetOps } from './commands.js';
4
6
  import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
5
- import { type OursOps } from './ours-client.js';
7
+ import { type OursToolClient } from './mcp.js';
6
8
  import { type OwnerUpdatePhase } from './notices.js';
7
9
  import { type OwnerEntry } from './state.js';
8
10
  import { type OwnerTaskPhase } from './tasks.js';
@@ -15,8 +17,15 @@ export interface OwnerChannelOptions {
15
17
  session: SessionHandle;
16
18
  stateDir: string;
17
19
  env?: Record<string, string>;
20
+ command?: string;
18
21
  log(line: string): void;
19
- client?: OursOps;
22
+ client?: OursToolClient;
23
+ /** Legacy child-process test seam; production uses the direct notification API. */
24
+ watch?: (identity: string) => ChildProcessWithoutNullStreams;
25
+ /** Test seam for the production direct notification long-poll. */
26
+ watchFetch?: FetchLike;
27
+ /** Test seam for the long-poll stall bound; production uses OWNER_WATCH_STALL_MS. */
28
+ watchStallMs?: number;
20
29
  /** Test seam; production uses the detached ours-fleet CLI (`fleetCliOps`). */
21
30
  fleet?: OwnerFleetOps;
22
31
  /** Forwarded to fleet CLI invocations spawned for owner commands. */
@@ -109,13 +118,7 @@ export type { OwnerUpdatePhase } from './notices.js';
109
118
  export interface OwnerContact {
110
119
  cid: string;
111
120
  name: string;
112
- /** Structural, from which daemon collection the row came: established or pending. */
113
121
  status: string;
114
- /**
115
- * Retained for the `ours-fleet owner contact list` column. The daemon's typed
116
- * contact view has no such field, so it is always absent; it is not inferred
117
- * from anything a contact controls.
118
- */
119
122
  kind?: string;
120
123
  human?: {
121
124
  cid?: string;
@@ -133,7 +136,6 @@ export declare class OwnerChannel implements OwnerChannelHandle {
133
136
  private readonly authorizations;
134
137
  private readonly conversations;
135
138
  private readonly tasks;
136
- private readonly messageRecovery;
137
139
  private readonly attachmentRecovery;
138
140
  private readonly attachmentConfig;
139
141
  private readonly attachmentRoot;
@@ -142,7 +144,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
142
144
  * (a crash must replay them) but must not be queued twice while live.
143
145
  */
144
146
  private readonly inFlight;
145
- /** Wires already NACKed to the managed agent, so a history replay stays quiet. */
147
+ /** Wires already NACKed to the managed agent, so a deferred replay stays quiet. */
146
148
  private readonly relayNacks;
147
149
  /**
148
150
  * fleet.yaml declares the restart baseline; `/comments on|off` changes only
@@ -153,6 +155,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
153
155
  private readonly commentsBaseline;
154
156
  private commentsEnabled;
155
157
  private stopping;
158
+ private watchProcess?;
156
159
  private watchTask?;
157
160
  private watchAbort?;
158
161
  private drainTask?;
@@ -171,12 +174,6 @@ export declare class OwnerChannel implements OwnerChannelHandle {
171
174
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
172
175
  notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
173
176
  private manageNow;
174
- /**
175
- * The daemon reports established contacts and pending introductions as two
176
- * separate collections, so the status is structural rather than a word parsed
177
- * out of a rendered line. Nothing here can be spoofed by a contact's own
178
- * display name.
179
- */
180
177
  private contacts;
181
178
  private contact;
182
179
  private assertCid;
@@ -190,15 +187,6 @@ export declare class OwnerChannel implements OwnerChannelHandle {
190
187
  private safeTaskReport;
191
188
  private safeProactiveMessage;
192
189
  private drainAll;
193
- /**
194
- * Claim the exact oldest unread SQLite batch before marking it read.
195
- * The journal contains only wire IDs and sequence numbers; bodies remain in
196
- * the daemon's persistent history and are recovered with getHistoryItem.
197
- */
198
- private claimMessages;
199
- private messageClaim;
200
- private historyMessage;
201
- private attachmentMetadata;
202
190
  private attachmentGroups;
203
191
  private handleAttachmentGroup;
204
192
  private handle;
@@ -236,7 +224,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
236
224
  private managedAttachmentReplyWire;
237
225
  /**
238
226
  * One bounded NACK per wire: an unroutable or refused relay must be visible
239
- * to the authenticated agent, while its history replays stay quiet. NACK
227
+ * to the authenticated agent, while its deferred replays stay quiet. NACK
240
228
  * delivery is best-effort — it must never make the failure worse.
241
229
  */
242
230
  private nackManagedAgent;
@@ -250,6 +238,16 @@ export declare class OwnerChannel implements OwnerChannelHandle {
250
238
  private warnOwnerOfUnauthorizedSender;
251
239
  private effectiveOwners;
252
240
  private authorizationIntegrity;
241
+ /**
242
+ * Report what the session actually did with the prompt, not what the config
243
+ * asked for. `interrupt: true` used to be reported as "your request
244
+ * interrupted the previous task" unconditionally; the session now answers
245
+ * whether anything was cancelled, whether the request is queued behind
246
+ * earlier prompts, or whether it is held until the current task reaches a
247
+ * safe stopping point. Backends that report no delivery state keep the old
248
+ * queuedBehind-based wording.
249
+ */
250
+ private acceptanceNotice;
253
251
  private complete;
254
252
  private commentsState;
255
253
  /** Model-authored commentary only; raw protocol/tool data never reaches here. */
@@ -269,12 +267,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
269
267
  private progressPhase;
270
268
  private watchLoop;
271
269
  /**
272
- * `recovered` distinguishes a first-ever start from unreadable persisted
273
- * diagnostics. Notification correctness does not depend on this state: every
274
- * establishment drains and then replays SDK hints from offset zero.
270
+ * `recovered` distinguishes a first-ever start (no state, nothing lost) from a
271
+ * cursor we HAD and can no longer read. Only the latter is a recovery, and the
272
+ * caller needs to know because the reason it reports is the only evidence a
273
+ * durable cursor was ever lost.
275
274
  */
276
275
  private readWatchState;
277
276
  private writeWatchState;
277
+ /** Compatibility path for injected child-process tests; production is direct. */
278
+ private legacyWatchLoop;
278
279
  private errorText;
279
280
  private logError;
280
281
  }