@ours.network/fleet 0.19.0-nightly.9 → 1.0.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 (114) hide show
  1. package/README.md +66 -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 +117 -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 +731 -219
  49. package/dist/rooms-tasks/close.d.ts +35 -0
  50. package/dist/rooms-tasks/close.js +190 -0
  51. package/dist/rooms-tasks/cowork-adapter.d.ts +24 -0
  52. package/dist/rooms-tasks/cowork-adapter.js +120 -0
  53. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  54. package/dist/rooms-tasks/external-worker.js +40 -0
  55. package/dist/rooms-tasks/index.d.ts +2 -0
  56. package/dist/rooms-tasks/index.js +2 -0
  57. package/dist/rooms-tasks/markdown.d.ts +49 -0
  58. package/dist/rooms-tasks/markdown.js +206 -0
  59. package/dist/rooms-tasks/member-startup.d.ts +51 -0
  60. package/dist/rooms-tasks/member-startup.js +151 -0
  61. package/dist/rooms-tasks/provision.d.ts +8 -0
  62. package/dist/rooms-tasks/provision.js +418 -97
  63. package/dist/rooms-tasks/room-state.d.ts +13 -1
  64. package/dist/rooms-tasks/room-state.js +149 -1
  65. package/dist/rooms-tasks/task-state.d.ts +13 -2
  66. package/dist/rooms-tasks/task-state.js +131 -3
  67. package/dist/rooms-tasks/terminal.d.ts +23 -0
  68. package/dist/rooms-tasks/terminal.js +56 -0
  69. package/dist/rooms-tasks/types.d.ts +108 -2
  70. package/dist/runner.d.ts +48 -0
  71. package/dist/runner.js +252 -100
  72. package/dist/session/acp.d.ts +106 -2
  73. package/dist/session/acp.js +217 -14
  74. package/dist/session/activity.d.ts +31 -0
  75. package/dist/session/activity.js +48 -0
  76. package/dist/session/control.d.ts +1 -1
  77. package/dist/session/conversation-normalizer.d.ts +7 -1
  78. package/dist/session/conversation-normalizer.js +154 -11
  79. package/dist/session/conversation-store.js +1 -1
  80. package/dist/session/conversation-types.d.ts +25 -6
  81. package/dist/session/types.d.ts +35 -0
  82. package/dist/spawn.d.ts +4 -2
  83. package/dist/spawn.js +39 -26
  84. package/dist/supervisor/launchd.d.ts +2 -2
  85. package/dist/supervisor/launchd.js +4 -4
  86. package/dist/supervisor/systemd.js +4 -4
  87. package/dist/supervisor/types.d.ts +1 -1
  88. package/dist/temp-lifecycle.d.ts +14 -2
  89. package/dist/temp-lifecycle.js +90 -0
  90. package/dist/watchdog/alerts.d.ts +2 -2
  91. package/dist/watchdog/alerts.js +2 -2
  92. package/dist/watchdog/briefing.d.ts +5 -5
  93. package/dist/watchdog/briefing.js +13 -6
  94. package/dist/watchdog/config.js +1 -1
  95. package/dist/watchdog/query.d.ts +2 -2
  96. package/dist/watchdog/query.js +2 -2
  97. package/dist/watchdog/report.js +2 -2
  98. package/dist/watchdog/run.d.ts +2 -2
  99. package/dist/watchdog/run.js +6 -6
  100. package/dist/watchdog/scheduler.d.ts +7 -7
  101. package/dist/watchdog/scheduler.js +10 -10
  102. package/dist/watchdog/service.d.ts +4 -4
  103. package/dist/watchdog/service.js +5 -6
  104. package/dist/watchdog/store.d.ts +4 -4
  105. package/dist/watchdog/store.js +4 -4
  106. package/dist/web/runtime.js +1 -1
  107. package/dist/web/server.js +1 -1
  108. package/dist/web/topology-promote.js +1 -1
  109. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  110. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  111. package/dist/web-app/index.html +1 -1
  112. package/dist/worklog.d.ts +7 -1
  113. package/dist/worklog.js +191 -39
  114. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ /**
2
+ * How long after the agent's last session update it still counts as working.
3
+ *
4
+ * FLEET-002: a wake delivered by ACP steering answers `startedNewTurn` and runs
5
+ * an entire turn that fleet never receives a `session/prompt` response for (ACP
6
+ * has no turn-end session update), so `readiness` stays `idle` throughout. Tool
7
+ * reservations and update recency are the only activity evidence fleet holds.
8
+ * The trade-off is deliberate and one-directional: at worst a role reads busy
9
+ * for one window after it genuinely stopped, instead of reading ready — or
10
+ * being classified stalled — while it is executing tools.
11
+ */
12
+ export const ACTIVITY_WINDOW_MS = 60_000;
13
+ /**
14
+ * Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
15
+ * cannot see the agent (tmux) has no evidence, and no evidence must never be
16
+ * reported as "doing nothing".
17
+ */
18
+ export function classifyActivity(activity, now = Date.now()) {
19
+ if (!activity)
20
+ return { state: 'unobservable' };
21
+ const lastUpdate = activity.lastUpdateAt ? Date.parse(activity.lastUpdateAt) : NaN;
22
+ const recent = Number.isFinite(lastUpdate) && now - lastUpdate <= ACTIVITY_WINDOW_MS;
23
+ return {
24
+ state: activity.activeToolCalls > 0 || recent ? 'active' : 'quiet',
25
+ activeToolCalls: activity.activeToolCalls,
26
+ ...(activity.lastUpdateAt ? { lastUpdateAt: activity.lastUpdateAt } : {}),
27
+ };
28
+ }
29
+ /**
30
+ * One operator-facing line that never lets turn occupancy pose as liveness:
31
+ * the readiness value is labelled as the turn field it is, and the activity
32
+ * verdict is stated separately with the evidence behind it.
33
+ */
34
+ export function describeSessionState(readiness, activity, now = Date.now()) {
35
+ const observed = classifyActivity(activity, now);
36
+ const evidence = [];
37
+ if (observed.activeToolCalls)
38
+ evidence.push(`${observed.activeToolCalls} tool calls in flight`);
39
+ if (observed.lastUpdateAt) {
40
+ const age = Math.max(0, Math.round((now - Date.parse(observed.lastUpdateAt)) / 1000));
41
+ if (Number.isFinite(age))
42
+ evidence.push(`last agent update ${age}s ago`);
43
+ }
44
+ const detail = observed.state === 'unobservable'
45
+ ? 'no agent-side evidence on this backend'
46
+ : evidence.join(', ') || 'no updates yet';
47
+ return `turn: ${readiness ?? 'unknown'} (turn occupancy only) | activity: ${observed.state} (${detail})`;
48
+ }
@@ -47,7 +47,7 @@ export interface ControlResponse {
47
47
  */
48
48
  export declare function livenessNote(kind: ControlFailureKind, name: string): string;
49
49
  /**
50
- * The result taxonomy an overseer judges a role by (7.2).
50
+ * The result taxonomy an overseer judges a role by.
51
51
  *
52
52
  * One console command is not a liveness verdict. `peek` and `send` can fail for
53
53
  * five distinct reasons and succeed for one, and only ONE of the six says the
@@ -9,8 +9,14 @@ import type { AdapterMeta, ConversationEventKind, ConversationPayload } from './
9
9
  * and quarantines namespaced `_meta` so unknown extensions cannot leak into
10
10
  * generic rendering paths.
11
11
  */
12
- /** Cap for any single normalized text payload (spec §5.3). */
12
+ /** Cap for any single normalized text payload. */
13
13
  export declare const MAX_TEXT_BYTES: number;
14
+ /** Cap for each retained side of an oversized snapshot-style file diff. */
15
+ export declare const MAX_DIFF_TEXT_BYTES: number;
16
+ /** Cap for attacker-controlled filesystem paths while retaining their useful basename tail. */
17
+ export declare const MAX_PATH_BYTES: number;
18
+ /** Hard cap for the complete normalized update before the durable event envelope is added. */
19
+ export declare const MAX_NORMALIZED_UPDATE_BYTES: number;
14
20
  /** Cap for one adapter `_meta` namespace value. */
15
21
  export declare const MAX_META_BYTES: number;
16
22
  /** Cap for serialized raw tool input/output retained as structured JSON. */
@@ -8,8 +8,14 @@ import { createHash } from 'node:crypto';
8
8
  * and quarantines namespaced `_meta` so unknown extensions cannot leak into
9
9
  * generic rendering paths.
10
10
  */
11
- /** Cap for any single normalized text payload (spec §5.3). */
11
+ /** Cap for any single normalized text payload. */
12
12
  export const MAX_TEXT_BYTES = 256 * 1024;
13
+ /** Cap for each retained side of an oversized snapshot-style file diff. */
14
+ export const MAX_DIFF_TEXT_BYTES = 64 * 1024;
15
+ /** Cap for attacker-controlled filesystem paths while retaining their useful basename tail. */
16
+ export const MAX_PATH_BYTES = 4 * 1024;
17
+ /** Hard cap for the complete normalized update before the durable event envelope is added. */
18
+ export const MAX_NORMALIZED_UPDATE_BYTES = 320 * 1024;
13
19
  /** Cap for one adapter `_meta` namespace value. */
14
20
  export const MAX_META_BYTES = 16 * 1024;
15
21
  /** Cap for serialized raw tool input/output retained as structured JSON. */
@@ -27,6 +33,28 @@ function truncateUtf8(text, maxBytes) {
27
33
  const buffer = Buffer.from(text).subarray(0, maxBytes);
28
34
  return buffer.toString('utf8').replace(/�+$/u, '');
29
35
  }
36
+ /** Keep a UTF-8-safe suffix. Paths and identifiers have no line semantics. */
37
+ function truncateUtf8Tail(text, maxBytes) {
38
+ const buffer = Buffer.from(text);
39
+ let start = Math.max(0, buffer.length - maxBytes);
40
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
41
+ start++;
42
+ return { text: buffer.subarray(start).toString('utf8'), omittedPrefixBytes: start };
43
+ }
44
+ function boundedPath(raw) {
45
+ const path = asString(raw) ?? '';
46
+ const pathBytes = Buffer.byteLength(path);
47
+ if (pathBytes <= MAX_PATH_BYTES)
48
+ return { path };
49
+ const retained = truncateUtf8Tail(path, MAX_PATH_BYTES);
50
+ return {
51
+ path: retained.text,
52
+ pathBytes,
53
+ pathTruncated: true,
54
+ pathDigest: digest24(path),
55
+ pathOmittedPrefixBytes: retained.omittedPrefixBytes,
56
+ };
57
+ }
30
58
  function cappedText(raw, redact) {
31
59
  const text = asString(raw) ?? '';
32
60
  const bytes = Buffer.byteLength(text);
@@ -39,6 +67,95 @@ function cappedText(raw, redact) {
39
67
  truncated: true, digest: digest24(text),
40
68
  };
41
69
  }
70
+ /** Keep the newest UTF-8 tail, aligning to a whole line whenever one fits. */
71
+ function cappedTextTail(text, maxBytes) {
72
+ const buffer = Buffer.from(text);
73
+ const bytes = buffer.length;
74
+ if (bytes <= maxBytes)
75
+ return { text, bytes };
76
+ let start = bytes - maxBytes;
77
+ while (start < bytes && (buffer[start] & 0xc0) === 0x80)
78
+ start++;
79
+ let startsMidLine = start > 0 && buffer[start - 1] !== 0x0a;
80
+ if (startsMidLine) {
81
+ const newline = buffer.indexOf(0x0a, start);
82
+ if (newline >= 0 && newline + 1 < bytes) {
83
+ start = newline + 1;
84
+ startsMidLine = false;
85
+ }
86
+ }
87
+ return {
88
+ text: buffer.subarray(start).toString('utf8'), bytes,
89
+ truncated: true, digest: digest24(text), omittedPrefixBytes: start,
90
+ ...(startsMidLine ? { startsMidLine: true } : {}),
91
+ };
92
+ }
93
+ /** Common unchanged edges in UTF-16 indices, adjusted away from split surrogates. */
94
+ function commonEdges(oldText, newText) {
95
+ const limit = Math.min(oldText.length, newText.length);
96
+ let prefix = 0;
97
+ while (prefix < limit && oldText.charCodeAt(prefix) === newText.charCodeAt(prefix))
98
+ prefix++;
99
+ if (prefix > 0 && prefix < limit
100
+ && oldText.charCodeAt(prefix) >= 0xdc00 && oldText.charCodeAt(prefix) <= 0xdfff)
101
+ prefix--;
102
+ let oldEnd = oldText.length;
103
+ let newEnd = newText.length;
104
+ while (oldEnd > prefix && newEnd > prefix
105
+ && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
106
+ oldEnd--;
107
+ newEnd--;
108
+ }
109
+ // A suffix must never begin at the low half of a surrogate pair.
110
+ if (oldEnd < oldText.length && oldText.charCodeAt(oldEnd) >= 0xdc00
111
+ && oldText.charCodeAt(oldEnd) <= 0xdfff) {
112
+ oldEnd++;
113
+ newEnd++;
114
+ }
115
+ return { prefix, suffix: oldText.length - oldEnd };
116
+ }
117
+ function normalizedDiff(item, redact) {
118
+ const path = boundedPath(item.path);
119
+ const oldText = asString(item.oldText);
120
+ const newText = asString(item.newText) ?? '';
121
+ // Preserve the established small-diff contract exactly. Redacted turns also
122
+ // retain their established placeholder shape and never derive private text.
123
+ if (redact !== undefined || oldText === undefined
124
+ || (Buffer.byteLength(oldText) <= MAX_TEXT_BYTES
125
+ && Buffer.byteLength(newText) <= MAX_TEXT_BYTES)) {
126
+ return {
127
+ type: 'diff', ...path,
128
+ newText: cappedText(newText, redact),
129
+ ...(oldText !== undefined ? { oldText: cappedText(oldText, redact) } : {}),
130
+ };
131
+ }
132
+ // ACP adapters may describe an append by sending two complete file snapshots.
133
+ // Persist only the changed region: otherwise a multi-megabyte historical file
134
+ // contributes its prefix twice while the current append disappears past the cap.
135
+ const { prefix, suffix } = commonEdges(oldText, newText);
136
+ const oldEnd = oldText.length - suffix;
137
+ const newEnd = newText.length - suffix;
138
+ const oldDelta = oldText.slice(prefix, oldEnd);
139
+ const newDelta = newText.slice(prefix, newEnd);
140
+ const beforeBytes = Buffer.byteLength(oldText);
141
+ const afterBytes = Buffer.byteLength(newText);
142
+ const commonPrefixBytes = Buffer.byteLength(oldText.slice(0, prefix));
143
+ const commonSuffixBytes = Buffer.byteLength(oldText.slice(oldEnd));
144
+ const operation = oldDelta.length === 0 && newDelta.length === 0
145
+ ? 'noop'
146
+ : oldDelta.length === 0 && prefix === oldText.length
147
+ ? 'append'
148
+ : newDelta.length === 0
149
+ ? 'delete'
150
+ : prefix === 0 && suffix === 0 ? 'replace' : 'edit';
151
+ return {
152
+ type: 'diff', ...path, operation, beforeBytes, afterBytes,
153
+ commonPrefixBytes, commonSuffixBytes, bounded: true,
154
+ newText: cappedTextTail(newDelta, MAX_DIFF_TEXT_BYTES),
155
+ ...(oldDelta.length > 0
156
+ ? { oldText: cappedTextTail(oldDelta, MAX_DIFF_TEXT_BYTES) } : {}),
157
+ };
158
+ }
42
159
  function normalizedText(raw, redact) {
43
160
  const text = asString(raw) ?? '';
44
161
  const bytes = Buffer.byteLength(text);
@@ -189,12 +306,7 @@ function normalizeToolContent(raw, redact) {
189
306
  return raw.filter(isRecord).map((item) => {
190
307
  switch (item.type) {
191
308
  case 'diff':
192
- return {
193
- type: 'diff',
194
- path: asString(item.path) ?? '',
195
- newText: cappedText(item.newText, redact),
196
- ...(item.oldText != null ? { oldText: cappedText(item.oldText, redact) } : {}),
197
- };
309
+ return normalizedDiff(item, redact);
198
310
  case 'terminal':
199
311
  return { type: 'terminal', terminalId: asString(item.terminalId) ?? '' };
200
312
  case 'content':
@@ -220,7 +332,7 @@ function toolUpsert(update, snapshot, options) {
220
332
  payload.content = content;
221
333
  if (Array.isArray(update.locations)) {
222
334
  payload.locations = update.locations.filter(isRecord).map(location => ({
223
- path: asString(location.path) ?? '',
335
+ ...boundedPath(location.path),
224
336
  ...(asFiniteNumber(location.line) !== undefined ? { line: asFiniteNumber(location.line) } : {}),
225
337
  }));
226
338
  }
@@ -240,18 +352,49 @@ function unsupported(update) {
240
352
  }
241
353
  const kind = isRecord(update) ? asString(update.sessionUpdate) : undefined;
242
354
  return {
243
- sessionUpdate: kind ?? 'unknown',
355
+ sessionUpdate: truncateUtf8(kind ?? 'unknown', 256),
244
356
  bytes: Buffer.byteLength(serialized),
245
357
  preview: serialized.slice(0, MAX_UNSUPPORTED_PREVIEW_CHARS),
358
+ ...(Buffer.byteLength(serialized) > Buffer.byteLength(serialized.slice(0, MAX_UNSUPPORTED_PREVIEW_CHARS))
359
+ ? { digest: digest24(serialized) } : {}),
360
+ };
361
+ }
362
+ function capNormalizedUpdate(result, sessionUpdate) {
363
+ let serialized;
364
+ try {
365
+ serialized = JSON.stringify(result);
366
+ }
367
+ catch {
368
+ return {
369
+ kind: 'unsupported',
370
+ payload: {
371
+ sessionUpdate: truncateUtf8(sessionUpdate, 256),
372
+ bytes: 0,
373
+ preview: '[normalized update was not serializable]',
374
+ },
375
+ };
376
+ }
377
+ const bytes = Buffer.byteLength(serialized);
378
+ if (bytes <= MAX_NORMALIZED_UPDATE_BYTES)
379
+ return result;
380
+ return {
381
+ kind: 'unsupported',
382
+ payload: {
383
+ sessionUpdate: truncateUtf8(sessionUpdate, 256),
384
+ bytes,
385
+ digest: digest24(serialized),
386
+ preview: `[normalized update exceeded ${MAX_NORMALIZED_UPDATE_BYTES}-byte durable-event cap]`,
387
+ },
246
388
  };
247
389
  }
248
390
  export function normalizeSessionUpdate(update, options = {}) {
249
391
  const redact = options.redactText;
250
392
  const raw = update;
251
393
  if (!isRecord(raw) || typeof raw.sessionUpdate !== 'string')
252
- return { kind: 'unsupported', payload: unsupported(raw) };
394
+ return capNormalizedUpdate({ kind: 'unsupported', payload: unsupported(raw) }, 'unknown');
395
+ const sessionUpdate = raw.sessionUpdate;
253
396
  const adapterMeta = quarantineMeta(raw._meta);
254
- const withMeta = (result) => adapterMeta ? { ...result, adapterMeta } : result;
397
+ const withMeta = (result) => capNormalizedUpdate(adapterMeta ? { ...result, adapterMeta } : result, sessionUpdate);
255
398
  switch (raw.sessionUpdate) {
256
399
  case 'user_message_chunk':
257
400
  case 'agent_message_chunk': {
@@ -3,7 +3,7 @@ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, re
3
3
  import { join } from 'node:path';
4
4
  /**
5
5
  * Durable, append-only, single-writer conversation ledger for one role
6
- * (spec §5.3). The per-role runner is the only writer; readers page by seq.
6
+ * The per-role runner is the only writer; readers page by seq.
7
7
  *
8
8
  * Unlike `SessionEvents` (a bounded diagnostic projection that may drop
9
9
  * writes silently), this store is the transcript of record: an acknowledged
@@ -1,11 +1,11 @@
1
1
  import type { PromptOrigin, TurnCancellationSource, TurnOutcome } from './types.js';
2
2
  /**
3
- * Durable conversation domain schema (ACP web console, spec §5.2).
3
+ * Durable conversation domain schema for the ACP web console.
4
4
  *
5
5
  * `SessionEvent` in ./types.js remains the compact diagnostic projection; the
6
6
  * types here describe the durable per-role conversation ledger. Nothing in this
7
7
  * file touches the wire: ACP updates are reduced into these shapes by the
8
- * normalizer, and the store (phase 1) assigns `seq`/`eventId`/timestamps.
8
+ * normalizer, and the store assigns `seq`/`eventId`/timestamps.
9
9
  */
10
10
  export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
11
11
  /** A well-formed ACP update this version cannot represent. Bounded, never a crash. */
@@ -48,15 +48,33 @@ export interface CappedText {
48
48
  bytes: number;
49
49
  truncated?: true;
50
50
  digest?: string;
51
+ /** Bytes omitted from the front when the retained fragment is a tail. */
52
+ omittedPrefixBytes?: number;
53
+ /** The retained tail starts inside one logical line because that line alone exceeded the cap. */
54
+ startsMidLine?: true;
55
+ }
56
+ /** A path kept as a UTF-8-safe tail, with additive provenance only when capped. */
57
+ export interface BoundedPath {
58
+ path: string;
59
+ pathBytes?: number;
60
+ pathTruncated?: true;
61
+ pathDigest?: string;
62
+ pathOmittedPrefixBytes?: number;
51
63
  }
52
64
  export type NormalizedToolContent = {
53
65
  type: 'content';
54
66
  content: NormalizedContentBlock;
55
- } | {
67
+ } | BoundedPath & {
56
68
  type: 'diff';
57
- path: string;
58
69
  newText: CappedText;
59
70
  oldText?: CappedText;
71
+ /** Additive provenance for oversized snapshot diffs reduced to their changed region. */
72
+ operation?: 'append' | 'edit' | 'delete' | 'noop' | 'replace';
73
+ beforeBytes?: number;
74
+ afterBytes?: number;
75
+ commonPrefixBytes?: number;
76
+ commonSuffixBytes?: number;
77
+ bounded?: true;
60
78
  }
61
79
  /** A tool-owned display terminal reference — never a PTY attachment. */
62
80
  | {
@@ -105,8 +123,7 @@ export interface ToolUpsertPayload {
105
123
  kind?: string;
106
124
  status?: string;
107
125
  content?: NormalizedToolContent[];
108
- locations?: Array<{
109
- path: string;
126
+ locations?: Array<BoundedPath & {
110
127
  line?: number;
111
128
  }>;
112
129
  rawInput?: BoundedJson;
@@ -140,6 +157,8 @@ export interface UnsupportedPayload {
140
157
  /** The wire discriminant (or 'unknown' when even that was absent). */
141
158
  sessionUpdate: string;
142
159
  bytes: number;
160
+ /** Digest of the omitted original/normalized representation when it was bounded. */
161
+ digest?: string;
143
162
  /** Sanitized JSON preview, capped; enough to diagnose, never to exhaust. */
144
163
  preview?: string;
145
164
  }
@@ -1,5 +1,16 @@
1
1
  import type { SessionBackendId } from '../config.js';
2
2
  import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
3
+ /**
4
+ * TURN OCCUPANCY, and nothing else: `idle` means no fleet-tracked turn is in
5
+ * flight, which is exactly the question `arbiter.tryScheduled` asks before it
6
+ * admits a prompt. It is NOT a claim that the agent is doing nothing — a wake
7
+ * delivered through the `_session/steering` extension answers `startedNewTurn`
8
+ * and runs a whole turn that fleet never gets a `session/prompt` response for
9
+ * (ACP has no turn-end session update), so `readiness` stays `idle` for its
10
+ * entire duration. Anything reporting activity or liveness to a human must
11
+ * corroborate with `SessionSnapshot.activity` instead of reading `idle` here as
12
+ * "not working".
13
+ */
3
14
  export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
4
15
  export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
5
16
  export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
@@ -99,6 +110,15 @@ export declare function interruptOutcome(result: InterruptResult): InterruptOutc
99
110
  * stop here: the session has the prompt, and waiting for the turn to finish is
100
111
  * a different question with a different, much longer, timescale.
101
112
  */
113
+ /**
114
+ * What actually happened to an admitted prompt, so a caller reporting to a
115
+ * human can be accurate instead of repeating what it asked for.
116
+ *
117
+ * `interrupted` is only ever returned when a turn was really cancelled for this
118
+ * prompt. `deferred` says the session is busy with work this prompt could not
119
+ * safely pre-empt — the prompt is admitted and will run, just not yet.
120
+ */
121
+ export type PromptDelivery = 'started' | 'queued' | 'interrupted' | 'deferred';
102
122
  export interface QueuedPrompt {
103
123
  promptId: string;
104
124
  /** Turns already queued ahead of this one. 0 means it starts immediately. */
@@ -106,6 +126,8 @@ export interface QueuedPrompt {
106
126
  origin?: PromptOrigin;
107
127
  /** The turn's terminal result. Never rejects. */
108
128
  completion: Promise<TurnResult>;
129
+ /** Observed admission outcome. Absent on backends that do not report it. */
130
+ delivery?: PromptDelivery;
109
131
  }
110
132
  /**
111
133
  * How a session's process ended.
@@ -170,6 +192,19 @@ export interface SessionSnapshot {
170
192
  /** Exact harness-native approval/permission mode used by this runner. */
171
193
  nativeMode: string;
172
194
  };
195
+ /**
196
+ * Observed agent activity, independent of turn occupancy: the evidence a
197
+ * human-facing surface needs before calling a role idle. Absent on backends
198
+ * that cannot observe the agent at all (tmux), which is itself honest — no
199
+ * evidence is not evidence of inactivity.
200
+ */
201
+ activity?: SessionActivity;
202
+ }
203
+ export interface SessionActivity {
204
+ /** ACP tool calls currently reserved (lifecycle open or permission pending). */
205
+ activeToolCalls: number;
206
+ /** When the agent last sent ANY session update, replay excluded. */
207
+ lastUpdateAt?: string;
173
208
  }
174
209
  export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'monitor_delivery' | 'turn_stop' | 'error';
175
210
  /** What a settled permission request resolved to. */
package/dist/spawn.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { IsolationConfig } from './isolation/types.js';
2
- import { type ApprovalMode, type FilesystemMode, type ResolvedRole, type RoleConfig, type MonitorConfig, type SessionBackendId, type UnattendedMode } from './config.js';
2
+ import { type ApprovalMode, type FilesystemMode, type ResolvedRole, type RoleConfig, type MonitorConfig, type SessionBackendId, type UnattendedMode, type RoomStartupGate } from './config.js';
3
3
  import { type OpsDeps } from './ops.js';
4
4
  import { type CreationDeps, type CreationProvenance } from './creation.js';
5
5
  import './harness/claude-code.js';
@@ -46,12 +46,14 @@ export interface SpawnOpts {
46
46
  creationActionId?: string;
47
47
  /** Set only by a live role supervisor after a role-scoped proxy request. */
48
48
  callerRole?: string;
49
+ /** Trusted Fleet-internal gate for a Cowork room member; not a CLI/config surface. */
50
+ roomStartupGate?: RoomStartupGate;
49
51
  /** Internal provenance labels for values filled by the caller's supervisor. */
50
52
  inheritedFromCaller?: string[];
51
53
  /**
52
54
  * Path to a file holding exactly the existing `isolation:` mapping — the same
53
55
  * schema fleet.yaml uses, not a second policy language. The ONE new operator
54
- * input in this release (6.3).
56
+ * input in this release.
55
57
  */
56
58
  isolationFile?: string;
57
59
  overseeInterval?: string;
package/dist/spawn.js CHANGED
@@ -5,6 +5,7 @@ import { parse, stringify } from 'yaml';
5
5
  import { agentDir, fleetDDir } from './paths.js';
6
6
  import { validateIsolationConfig } from './isolation/policy.js';
7
7
  import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolveOwnerChannelConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
8
+ import { resolveRoleModelEnv } from './model-env.js';
8
9
  import { applyRole, up } from './ops.js';
9
10
  import { START_STAGGER_FILE } from './runner.js';
10
11
  import { buildProvenance, daemonIdentityInventoryProvisioner, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
@@ -191,7 +192,17 @@ export function spawnDryRun(o) {
191
192
  const harness = raw.harness ?? cfg.defaults.harness ?? 'claude-code';
192
193
  const defaultHarness = cfg.defaults.harness ?? 'claude-code';
193
194
  const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
194
- const model = resolveRoleModel(raw.model, raw.harness, cfg.defaults);
195
+ const authProxy = resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy);
196
+ // One resolution for the environment and the model it pins (src/model-env.ts).
197
+ const modelEnv = resolveRoleModelEnv({
198
+ harness,
199
+ model: resolveRoleModel(raw.model, raw.harness, cfg.defaults),
200
+ modelWasExplicit: raw.model !== undefined,
201
+ defaultsEnv: (cfg.defaults.env ?? {}),
202
+ roleEnv: raw.env,
203
+ ...(authProxy ? { authProxyBaseUrl: authProxy.base_url } : {}),
204
+ });
205
+ const model = modelEnv.model;
195
206
  const session = raw.session ?? cfg.defaults.session ?? 'tmux';
196
207
  const resolvedRole = {
197
208
  ...raw,
@@ -212,19 +223,13 @@ export function spawnDryRun(o) {
212
223
  monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
213
224
  owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, raw.owner_channel, session),
214
225
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, raw.worklog),
215
- auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy),
216
- };
217
- resolvedRole.env = {
218
- ...(cfg.defaults.env ?? {}),
219
- ...(raw.env ?? {}),
220
- ...(resolvedRole.auth_proxy
221
- ? { ANTHROPIC_BASE_URL: resolvedRole.auth_proxy.base_url }
222
- : {}),
226
+ auth_proxy: authProxy,
223
227
  };
228
+ resolvedRole.env = modelEnv.env;
224
229
  const adapter = getAdapter(resolvedRole.harness);
225
230
  if (resolvedRole.auth_proxy && resolvedRole.harness !== 'claude-code')
226
231
  throw new Error('auth_proxy is supported only by claude-code');
227
- const optionProblems = adapter.validateOptions(resolvedRole.harness_options);
232
+ const optionProblems = adapter.validateOptions(resolvedRole.harness_options, resolvedRole);
228
233
  if (optionProblems.length)
229
234
  throw new Error(optionProblems.map(problem => `${problem.path}: ${problem.message}`).join('; '));
230
235
  return {
@@ -236,7 +241,7 @@ export function spawnDryRun(o) {
236
241
  }
237
242
  /**
238
243
  * Which settings came from the operator, from fleet defaults, or from a
239
- * built-in (6.6). Built while the options are still separable — once they are
244
+ * built-in. Built while the options are still separable — once they are
240
245
  * merged into a ResolvedRole the distinction is gone.
241
246
  *
242
247
  * `env`, `bio`, `persona` and `harness_options` are deliberately absent: the
@@ -278,13 +283,13 @@ export async function spawnPermanent(o, deps, creation = {}) {
278
283
  readIsolationFile(o.isolationFile); // fail before reserving
279
284
  if (o.missionFile)
280
285
  readMissionFile(o.missionFile); // fail before reserving
281
- // Name AND identity reserved together, before anything is written or started
282
- // (6.4). A loser of the race creates no config, no state, no service.
286
+ // Reserve name and identity together before anything is written or started.
287
+ // A loser of the race creates no config, state, or service.
283
288
  creation.onStage?.('reserving');
284
289
  return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
285
290
  assertNameFree(o);
286
291
  const cfg = loadConfig(o.configPath);
287
- // Establish the identity BEFORE the service is enabled (7.3), and record
292
+ // Establish the identity BEFORE the service is enabled, and record
288
293
  // what was actually guaranteed so the briefing can say something true.
289
294
  creation.onStage?.('checking_identity');
290
295
  const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
@@ -317,7 +322,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
317
322
  rmSync(stateDir, { recursive: true, force: true }); },
318
323
  });
319
324
  // Journal the service registration BEFORE it happens, and undo only the
320
- // registrations this transaction actually created (6.2). `registered` is
325
+ // registrations this transaction actually created. `registered` is
321
326
  // filled by `up`'s onInstalled hook at the moment each registration is
322
327
  // made — not from its return value, which never arrives when `up` throws
323
328
  // after registering.
@@ -328,7 +333,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
328
333
  await deps.backend.uninstall(n); },
329
334
  });
330
335
  // Provenance is written BEFORE the role starts, so a role that fails to
331
- // launch still records how it was asked for (6.6).
336
+ // launch still records how it was asked for.
332
337
  const provenance = buildProvenance({
333
338
  role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
334
339
  settings: provenanceSettings(o, cfg.defaults),
@@ -378,8 +383,8 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
378
383
  // Retire only supervisors whose recorded owner is definitively stopped. This
379
384
  // bounded pass keeps the active roster clean without deleting old evidence.
380
385
  await reclaimStaleTempState();
381
- // Temporary roles go through the SAME reservation boundary as permanent ones
382
- // (6.4): a temp agent competes for the same names.
386
+ // Temporary roles go through the same reservation boundary as permanent ones:
387
+ // a temp agent competes for the same names.
383
388
  creation.onStage?.('reserving');
384
389
  return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
385
390
  creation.onStage?.('checking_identity');
@@ -408,7 +413,18 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
408
413
  };
409
414
  const harness = o.harness ?? defaultHarness ?? 'claude-code';
410
415
  const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
411
- const model = resolveRoleModel(o.model, o.harness, cfg.defaults);
416
+ const tempAuthProxy = resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy);
417
+ // An explicitly requested --model must reach the child, not just the banner
418
+ // (src/model-env.ts).
419
+ const modelEnv = resolveRoleModelEnv({
420
+ harness,
421
+ model: resolveRoleModel(o.model, o.harness, cfg.defaults),
422
+ modelWasExplicit: o.model !== undefined,
423
+ defaultsEnv: (cfg.defaults.env ?? {}),
424
+ roleEnv: fromOpts.env,
425
+ ...(tempAuthProxy ? { authProxyBaseUrl: tempAuthProxy.base_url } : {}),
426
+ });
427
+ const model = modelEnv.model;
412
428
  const session = o.session ?? cfg.defaults.session ?? 'tmux';
413
429
  const role = {
414
430
  ...fromOpts, // includes `isolation` when --isolation-file was given
@@ -423,18 +439,15 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
423
439
  harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
424
440
  permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
425
441
  permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
426
- // Temp agents inherit the fleet-wide monitor defaults via the snapshot (design §2).
442
+ // Temp agents inherit the fleet-wide monitor defaults via the snapshot.
427
443
  monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
428
444
  owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, fromOpts.owner_channel, session),
429
445
  worklog: resolveWorklogPolicy(cfg.defaults.worklog, fromOpts.worklog),
430
- auth_proxy: resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy),
446
+ auth_proxy: tempAuthProxy,
431
447
  sourceFile: '(temp)',
448
+ roomStartupGate: o.roomStartupGate,
432
449
  };
433
- role.env = {
434
- ...(cfg.defaults.env ?? {}),
435
- ...(fromOpts.env ?? {}),
436
- ...(role.auth_proxy ? { ANTHROPIC_BASE_URL: role.auth_proxy.base_url } : {}),
437
- };
450
+ role.env = modelEnv.env;
438
451
  if (role.auth_proxy && role.harness !== 'claude-code')
439
452
  throw new Error('auth_proxy is supported only by claude-code');
440
453
  onStage?.('writing_role');
@@ -6,7 +6,7 @@ export declare const labelFor: (name: string) => string;
6
6
  * asked of it cannot drift apart. They are not the same question:
7
7
  *
8
8
  * - `liveness` asks "does this role's context still exist" — a loaded job counts,
9
- * including one waiting between KeepAlive restarts (1.1);
9
+ * including one waiting between KeepAlive restarts;
10
10
  * - `install` asks "did the job I just bootstrapped actually START" — for which a
11
11
  * job that is loaded, not running, and has already exited once is a failure.
12
12
  *
@@ -44,7 +44,7 @@ export interface LaunchdJob {
44
44
  * - loaded and running, or waiting for a KeepAlive restart, or not running with
45
45
  * nothing exited yet (it simply has not been spawned yet — the asynchrony
46
46
  * RunAtLoad introduces) → NOT a failure;
47
- * - an unreadable probe → `unknown`, never a failure (1.1). launchd may be fine
47
+ * - an unreadable probe → `unknown`, never a failure. launchd may be fine
48
48
  * and the tool merely unable to answer.
49
49
  */
50
50
  export declare function classifyStart(job: LaunchdJob): {
@@ -23,7 +23,7 @@ function plist(name, binPath) {
23
23
  <key>Label</key><string>${labelFor(name)}</string>
24
24
  <key>ProgramArguments</key>
25
25
  <array><string>${binPath}</string><string>_run</string><string>${name}</string></array>
26
- <!-- The runner owns the child-session restart loop (3.2). launchd must only
26
+ <!-- The runner owns the child-session restart loop. launchd must only
27
27
  recover the runner PROCESS crashing: a bare KeepAlive would resume the
28
28
  uncounted relaunch loop and restart a deliberately held-down agent. -->
29
29
  <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
@@ -50,7 +50,7 @@ function plist(name, binPath) {
50
50
  * - loaded and running, or waiting for a KeepAlive restart, or not running with
51
51
  * nothing exited yet (it simply has not been spawned yet — the asynchrony
52
52
  * RunAtLoad introduces) → NOT a failure;
53
- * - an unreadable probe → `unknown`, never a failure (1.1). launchd may be fine
53
+ * - an unreadable probe → `unknown`, never a failure. launchd may be fine
54
54
  * and the tool merely unable to answer.
55
55
  */
56
56
  export function classifyStart(job) {
@@ -105,7 +105,7 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
105
105
  writeFileSync(plistPath(name), plist(name, binPath));
106
106
  await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // best-effort refresh
107
107
  // Undo only what WE wrote. A plist that was already there belongs to
108
- // whoever put it there, and rollback may never remove it (6.2).
108
+ // whoever put it there, and rollback may never remove it.
109
109
  const undo = async () => {
110
110
  if (existed)
111
111
  return;
@@ -117,7 +117,7 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
117
117
  // The plist is already on disk, carrying RunAtLoad. Throwing here means
118
118
  // `install` never returns `{created: true}`, so the creation transaction
119
119
  // records nothing and its rollback removes nothing — and a spawn that
120
- // failed at registration leaves a launch artifact behind (6.2). Undo our
120
+ // failed at registration leaves a launch artifact behind. Undo our
121
121
  // own partial write before throwing, and only when WE wrote it.
122
122
  await undo();
123
123
  throw new Error(`launchctl bootstrap ${labelFor(name)} failed: ${r.stderr.trim()}`);