@ours.network/fleet 0.15.1 → 0.15.4

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 (65) hide show
  1. package/README.md +49 -13
  2. package/dist/application/fleet-query-service.js +3 -0
  3. package/dist/application/model-catalog.d.ts +20 -0
  4. package/dist/application/model-catalog.js +57 -0
  5. package/dist/application/role-creation-service.d.ts +7 -0
  6. package/dist/application/role-creation-service.js +21 -4
  7. package/dist/application/role-removal-service.d.ts +32 -0
  8. package/dist/application/role-removal-service.js +87 -0
  9. package/dist/application/role-repository.js +13 -1
  10. package/dist/application/session-control.d.ts +74 -0
  11. package/dist/application/session-control.js +66 -1
  12. package/dist/application/types.d.ts +18 -0
  13. package/dist/briefing.js +21 -1
  14. package/dist/cli.js +39 -7
  15. package/dist/config.d.ts +4 -1
  16. package/dist/config.js +3 -2
  17. package/dist/creation.d.ts +6 -3
  18. package/dist/creation.js +5 -1
  19. package/dist/docs.d.ts +1 -1
  20. package/dist/docs.js +47 -11
  21. package/dist/fleet-proxy.d.ts +25 -0
  22. package/dist/fleet-proxy.js +38 -0
  23. package/dist/harness/claude-code.js +20 -3
  24. package/dist/harness/codex.js +14 -2
  25. package/dist/harness/types.d.ts +6 -1
  26. package/dist/index.d.ts +2 -1
  27. package/dist/index.js +1 -0
  28. package/dist/owner-channel/channel.d.ts +13 -0
  29. package/dist/owner-channel/channel.js +191 -9
  30. package/dist/owner-channel/state.d.ts +7 -1
  31. package/dist/owner-channel/state.js +41 -4
  32. package/dist/permissions.d.ts +5 -0
  33. package/dist/permissions.js +7 -0
  34. package/dist/runner.d.ts +2 -0
  35. package/dist/runner.js +86 -2
  36. package/dist/session/acp.d.ts +61 -1
  37. package/dist/session/acp.js +398 -20
  38. package/dist/session/arbiter.d.ts +10 -1
  39. package/dist/session/arbiter.js +24 -0
  40. package/dist/session/control.d.ts +33 -2
  41. package/dist/session/control.js +158 -5
  42. package/dist/session/conversation-normalizer.d.ts +34 -0
  43. package/dist/session/conversation-normalizer.js +356 -0
  44. package/dist/session/conversation-store.d.ts +88 -0
  45. package/dist/session/conversation-store.js +347 -0
  46. package/dist/session/conversation-types.d.ts +274 -0
  47. package/dist/session/conversation-types.js +1 -0
  48. package/dist/session/types.d.ts +40 -0
  49. package/dist/spawn.d.ts +6 -1
  50. package/dist/spawn.js +23 -16
  51. package/dist/web/auth.d.ts +1 -1
  52. package/dist/web/fleet-config-service.d.ts +47 -0
  53. package/dist/web/fleet-config-service.js +204 -0
  54. package/dist/web/runtime.js +14 -1
  55. package/dist/web/server.d.ts +6 -0
  56. package/dist/web/server.js +181 -9
  57. package/dist/web/topology.d.ts +31 -0
  58. package/dist/web/topology.js +61 -0
  59. package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
  60. package/dist/web-app/assets/index-COg4Azq1.css +1 -0
  61. package/dist/web-app/assets/index-Cde9auW0.js +10 -0
  62. package/dist/web-app/index.html +2 -2
  63. package/package.json +1 -1
  64. package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
  65. package/dist/web-app/assets/index-B6T8JLSd.js +0 -9
@@ -0,0 +1,38 @@
1
+ import { effectivePermissionMode } from './permissions.js';
2
+ /** Present only inside a managed role process. The CLI treats it as a routing hint, not authority. */
3
+ export const FLEET_PROXY_STATE_DIR_ENV = 'OURS_FLEET_PROXY_STATE_DIR';
4
+ export const FLEET_PROXY_CALLER_ENV = 'OURS_FLEET_PROXY_CALLER';
5
+ /**
6
+ * Fill only omitted spawn settings from the live caller. Explicit agent choices
7
+ * always win. This is convenience attribution, not an authorization boundary.
8
+ */
9
+ export function inheritCallerSpawnDefaults(caller, requested, configPath) {
10
+ const options = { ...requested };
11
+ const inherited = [];
12
+ const take = (key, value) => {
13
+ if (options[key] !== undefined || value === undefined)
14
+ return;
15
+ options[key] = value;
16
+ inherited.push(String(key));
17
+ };
18
+ const sameHarness = requested.harness === undefined || requested.harness === caller.harness;
19
+ take('harness', caller.harness);
20
+ take('session', caller.session);
21
+ take('cwd', caller.cwd);
22
+ take('coordinator', caller.name);
23
+ if (options.approval === undefined)
24
+ take('approval', effectivePermissionMode(caller).fleetMode);
25
+ take('filesystem', caller.permissions.filesystem);
26
+ take('unattended', caller.permissions.unattended);
27
+ take('monitorConfig', structuredClone(caller.monitor));
28
+ // A model name and native harness options are not portable across harnesses.
29
+ // When the caller explicitly switches harness, let that harness/fleet defaults
30
+ // select its model instead of copying (for example) a Codex model into Claude.
31
+ if (sameHarness)
32
+ take('model', caller.model);
33
+ options.configPath = configPath;
34
+ options.surface = 'agent';
35
+ options.callerRole = caller.name;
36
+ options.inheritedFromCaller = [...inherited];
37
+ return { options, inherited };
38
+ }
@@ -6,7 +6,8 @@ import { registerAdapter } from './registry.js';
6
6
  import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
8
  import { bundledAcpAgent } from './acp-agent.js';
9
- const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode'];
9
+ const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort'];
10
+ const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
10
11
  /** Claude Code's accepted --permission-mode values. */
11
12
  const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
12
13
  /**
@@ -23,8 +24,9 @@ const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPe
23
24
  export function nativePermissionMode(approval) {
24
25
  switch (approval) {
25
26
  case 'allow': return 'bypassPermissions';
27
+ case 'auto': return 'acceptEdits';
26
28
  case 'deny': return 'plan';
27
- default: return undefined; // 'ask' Claude's own default
29
+ default: return undefined; // ask uses Claude's native default
28
30
  }
29
31
  }
30
32
  /**
@@ -149,9 +151,13 @@ export function makeClaudeCodeAdapter(exec = realExec) {
149
151
  return [];
150
152
  if (typeof opts !== 'object' || Array.isArray(opts))
151
153
  return [{ path: 'harness_options', message: 'must be a map' }];
152
- return Object.keys(opts)
154
+ const errors = Object.keys(opts)
153
155
  .filter(k => !OPTION_KEYS.includes(k))
154
156
  .map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
157
+ const effort = opts.effort;
158
+ if (effort != null && !EFFORT_LEVELS.includes(effort))
159
+ errors.push({ path: 'harness_options.effort', message: `must be one of: ${EFFORT_LEVELS.join(', ')}` });
160
+ return errors;
155
161
  },
156
162
  async prepareSession(role, dirs) {
157
163
  // Pre-trust stays a HOST-side step: inside the sandbox ~/.claude.json is
@@ -189,7 +195,9 @@ export function makeClaudeCodeAdapter(exec = realExec) {
189
195
  buildLaunch(role, mode, s, prep) {
190
196
  const stateDir = roleStateDir(role);
191
197
  const pm = permissionMode(role);
198
+ const o = role.harness_options;
192
199
  const base = ['claude', ...(role.model ? ['--model', role.model] : []),
200
+ ...(o?.effort ? ['--effort', o.effort] : []),
193
201
  ...(pm ? ['--permission-mode', pm] : []),
194
202
  ...prep.argv, '--remote-control', role.name];
195
203
  const argv = mode === 'fresh'
@@ -256,6 +264,15 @@ export function makeClaudeCodeAdapter(exec = realExec) {
256
264
  capabilities: claudeCapabilities(native, role.permissions.filesystem),
257
265
  };
258
266
  },
267
+ effectivePermissionMode(role) {
268
+ const nativeMode = permissionMode(role) ?? 'default';
269
+ const fleetMode = nativeMode === 'bypassPermissions' ? 'allow'
270
+ : nativeMode === 'acceptEdits' || nativeMode === 'dontAsk' ? 'auto'
271
+ : nativeMode === 'default' || nativeMode === 'plan' ? 'ask' : undefined;
272
+ if (!fleetMode)
273
+ throw new Error(`unsupported Claude permission mode '${nativeMode}'`);
274
+ return { fleetMode, nativeMode };
275
+ },
259
276
  vocabulary: {
260
277
  bindTool: 'choose_identity',
261
278
  createTool: 'create_identity',
@@ -63,8 +63,10 @@ function approvalPolicy(role) {
63
63
  const approval = role.permissions?.approval;
64
64
  if (approval === 'allow')
65
65
  return 'never';
66
- if (approval === 'ask' || approval === 'deny')
66
+ if (approval === 'auto' || approval === 'deny')
67
67
  return 'on-request';
68
+ if (approval === 'ask')
69
+ return 'untrusted';
68
70
  return undefined;
69
71
  }
70
72
  if (!APPROVAL_POLICIES.includes(a))
@@ -259,7 +261,8 @@ export function makeCodexAdapter(exec = realExec) {
259
261
  };
260
262
  },
261
263
  translatePermissions(permissions) {
262
- const approval = permissions.approval === 'allow' ? 'never' : 'on-request';
264
+ const approval = permissions.approval === 'allow' ? 'never'
265
+ : permissions.approval === 'ask' ? 'untrusted' : 'on-request';
263
266
  const sandbox = permissions.filesystem === 'read-only'
264
267
  ? 'read-only'
265
268
  : permissions.filesystem === 'unrestricted'
@@ -285,6 +288,15 @@ export function makeCodexAdapter(exec = realExec) {
285
288
  capabilities: codexCapabilities(approval, sandbox),
286
289
  };
287
290
  },
291
+ effectivePermissionMode(role) {
292
+ const nativeMode = approvalPolicy(role) ?? 'untrusted';
293
+ const fleetMode = nativeMode === 'never' ? 'allow'
294
+ : nativeMode === 'on-request' ? 'auto'
295
+ : nativeMode === 'untrusted' ? 'ask' : undefined;
296
+ if (!fleetMode)
297
+ throw new Error(`unsupported Codex approval policy '${nativeMode}'`);
298
+ return { fleetMode, nativeMode };
299
+ },
288
300
  vocabulary: {
289
301
  bindTool: 'choose_identity',
290
302
  createTool: 'create_identity',
@@ -1,4 +1,4 @@
1
- import type { CommonPermissions, ResolvedRole } from '../config.js';
1
+ import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
2
2
  export interface PrereqCheck {
3
3
  name: string;
4
4
  ok: boolean;
@@ -106,6 +106,11 @@ export interface HarnessAdapter {
106
106
  * agent's default. Omit for a harness whose ACP agent has no modes.
107
107
  */
108
108
  acpPermissionModeId?(role: ResolvedRole): string | undefined;
109
+ /** Effective portable policy and harness-native approval mode after native overrides win. */
110
+ effectivePermissionMode?(role: ResolvedRole): {
111
+ fleetMode: FleetPermissionMode;
112
+ nativeMode: string;
113
+ };
109
114
  /**
110
115
  * REQUIRED. Every adapter must either translate neutral permissions or
111
116
  * explicitly declare that it cannot. Enforced at registration.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
- export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, SessionOptions, OwnerChannelConfig, } from './config.js';
2
+ export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, FleetPermissionMode, ApprovalMode, SessionOptions, OwnerChannelConfig, } from './config.js';
3
3
  export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, AcpLaunch, PermissionTranslation, RoleDirs, ValidationError, } from './harness/types.js';
4
4
  export type { SessionHandle, SessionSnapshot, SessionEvent, TurnResult, } from './session/types.js';
5
5
  export { AcpSession } from './session/acp.js';
@@ -9,6 +9,7 @@ export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.j
9
9
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
10
10
  export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
11
11
  export { generateBriefing } from './briefing.js';
12
+ export { effectivePermissionMode } from './permissions.js';
12
13
  export { pickBackend } from './supervisor/index.js';
13
14
  export type { SupervisorBackend } from './supervisor/types.js';
14
15
  export { up, down, restartRoles, rmRole, applyRole } from './ops.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.j
6
6
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
7
7
  export { codexAdapter, makeCodexAdapter } from './harness/codex.js';
8
8
  export { generateBriefing } from './briefing.js';
9
+ export { effectivePermissionMode } from './permissions.js';
9
10
  export { pickBackend } from './supervisor/index.js';
10
11
  export { up, down, restartRoles, rmRole, applyRole } from './ops.js';
11
12
  export { spawnPermanent, spawnTemp, buildRoleConfig, profileValues, validateSpawnOpts, } from './spawn.js';
@@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { type OwnerChannelConfig } from '../config.js';
3
3
  import type { SessionHandle } from '../session/types.js';
4
4
  import { type OwnerFleetOps } from './commands.js';
5
+ import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
5
6
  import { type OursToolClient } from './mcp.js';
6
7
  import { type OwnerUpdatePhase } from './notices.js';
7
8
  import { type OwnerEntry } from './state.js';
@@ -34,6 +35,8 @@ export interface OwnerChannelHandle {
34
35
  drain(): Promise<void>;
35
36
  close(): Promise<void>;
36
37
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
38
+ /** Fleet-owned deterministic lifecycle notice; absent on legacy test doubles. */
39
+ notifyFleetSpawn?(event: ManagedFleetSpawnResult): Promise<void>;
37
40
  }
38
41
  export type OwnerChannelManagementRequest = {
39
42
  action: 'contact_list';
@@ -155,6 +158,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
155
158
  drain(): Promise<void>;
156
159
  close(): Promise<void>;
157
160
  manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
161
+ notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
158
162
  private manageNow;
159
163
  private contacts;
160
164
  private contact;
@@ -195,6 +199,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
195
199
  private isAgentSender;
196
200
  private isEffectiveOwner;
197
201
  private relayManagedAgentMessage;
202
+ /**
203
+ * Caption and files are admitted as one relay transaction. The authenticated
204
+ * route and optional source wire are fixed before bytes are retrieved, and no
205
+ * outbound part is emitted until every file passes admission. A transport
206
+ * failure after emission starts is durably uncertain and never blind-retried;
207
+ * the managed agent receives one bounded NACK for the whole transaction.
208
+ */
209
+ private handleManagedAgentAttachmentGroup;
210
+ private managedAttachmentReplyWire;
198
211
  /**
199
212
  * One bounded NACK per wire: an unroutable or refused relay must be visible
200
213
  * to the authenticated agent, while its deferred replays stay quiet. NACK
@@ -157,6 +157,20 @@ export class OwnerChannel {
157
157
  this.managementTail = run.then(() => undefined, () => undefined);
158
158
  return run;
159
159
  }
160
+ notifyFleetSpawn(event) {
161
+ const run = this.managementTail.then(async () => {
162
+ if (!this.ready || this.stopping)
163
+ throw new Error('owner-channel MCP client is unavailable');
164
+ const model = event.model ? `, model ${event.model}` : '';
165
+ const monitor = `${event.monitor.mode} monitor${event.monitor.interrupt ? ' with interruption' : ''}`;
166
+ const inherited = event.inherited.length
167
+ ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
168
+ await this.sendProactiveMessage(`🧑‍💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
169
+ + `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
170
+ });
171
+ this.managementTail = run.then(() => undefined, () => undefined);
172
+ return run;
173
+ }
160
174
  async manageNow(request) {
161
175
  if (!this.ready || this.stopping)
162
176
  throw new Error('owner-channel MCP client is unavailable');
@@ -337,13 +351,15 @@ export class OwnerChannel {
337
351
  await send;
338
352
  return { action: request.action, requestId: request.requestId, sequence };
339
353
  }
340
- async sendProactiveMessage(messageValue) {
354
+ async sendProactiveMessage(messageValue, digestValue = messageValue, minIntervalMs) {
341
355
  if (!this.authorizationIntegrity().ok)
342
356
  throw new Error('owner authorization state is corrupt; proactive messages are disabled');
343
357
  const message = this.safeProactiveMessage(messageValue);
344
358
  const route = this.conversations.route(this.effectiveOwners());
345
- const digest = createHash('sha256').update(message).digest('hex');
346
- const sending = this.conversations.beginSend(route.contact, digest);
359
+ const digest = createHash('sha256').update(digestValue).digest('hex');
360
+ const sending = minIntervalMs === undefined
361
+ ? this.conversations.beginSend(route.contact, digest)
362
+ : this.conversations.beginSend(route.contact, digest, Date.now(), minIntervalMs);
347
363
  try {
348
364
  if (!this.isEffectiveOwner(route.contact))
349
365
  throw new Error('selected proactive owner is no longer authorized');
@@ -542,7 +558,18 @@ export class OwnerChannel {
542
558
  if (exact.some(file => file.senderId !== recovery.contact))
543
559
  continue;
544
560
  exact.forEach(file => used.add(file.wireId));
545
- groups.push({ files: exact, recovery });
561
+ const caption = recovery.originWireId === recovery.fileWireIds[0]
562
+ ? undefined : messageByWire.get(recovery.originWireId);
563
+ if (caption && this.sender(caption).id !== recovery.contact)
564
+ continue;
565
+ // A managed-agent caption is deferred before retrieval. If recovery sees
566
+ // the processed file before that body is replayed, keep the file reserved
567
+ // by the journal until both halves are present again.
568
+ if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
569
+ continue;
570
+ if (caption)
571
+ consumed.add(caption);
572
+ groups.push({ files: exact, recovery, ...(caption ? { caption } : {}) });
546
573
  }
547
574
  for (const file of files) {
548
575
  if (used.has(file.wireId))
@@ -578,6 +605,9 @@ export class OwnerChannel {
578
605
  if (handledWireIds.some(wire => this.inFlight.has(wire)))
579
606
  return false;
580
607
  const sender = { id: group.files[0].senderId, name: group.files[0].senderName };
608
+ if (group.files.every(file => this.isAgentSender(file.senderId))
609
+ && (!group.caption || this.isAgentSender(this.sender(group.caption).id)))
610
+ return this.handleManagedAgentAttachmentGroup(group, handledWireIds, sender.id);
581
611
  if (group.files.some(file => file.senderId !== sender.id)
582
612
  || !this.isEffectiveOwner(sender.id)) {
583
613
  this.options.log(`[${this.options.role}] owner channel ignored unauthorized attachment sender ${sender.id}`);
@@ -590,6 +620,14 @@ export class OwnerChannel {
590
620
  catch { }
591
621
  return true;
592
622
  }
623
+ // Owner files are requests too: retain their authenticated source wire so
624
+ // a later managed-agent attachment reply cannot drift to a newer owner.
625
+ try {
626
+ this.conversations.recordInbound(sender.id, originWireId);
627
+ }
628
+ catch (error) {
629
+ this.logError('owner attachment route update failed', error);
630
+ }
593
631
  const rejection = !this.attachmentRecovery.integrity()
594
632
  ? 'attachment recovery state is unavailable'
595
633
  : validateAttachmentSelection(group.files, this.attachmentConfig);
@@ -633,7 +671,8 @@ export class OwnerChannel {
633
671
  const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, outbox, admitted, group.caption), {
634
672
  interrupt: this.options.config.interrupt,
635
673
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
636
- origin: { kind: 'owner', requestId },
674
+ origin: { kind: 'owner', requestId,
675
+ ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
637
676
  });
638
677
  const accepted = this.options.config.interrupt
639
678
  ? ownerNotices.receivedInterrupting()
@@ -745,9 +784,9 @@ export class OwnerChannel {
745
784
  // never prevent an authenticated owner from using the ordinary channel.
746
785
  this.logError('owner conversation route update failed', error);
747
786
  }
748
- const text = String(message.text ?? '').trim();
749
- if (isOwnerCommandText(text)) {
750
- await this.handleCommand(sender, text, wireId);
787
+ const text = String(message.text ?? '');
788
+ if (isOwnerCommandText(text.trim())) {
789
+ await this.handleCommand(sender, text.trim(), wireId);
751
790
  return true;
752
791
  }
753
792
  const requestId = this.requestId(wireId);
@@ -759,7 +798,7 @@ export class OwnerChannel {
759
798
  queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
760
799
  interrupt: this.options.config.interrupt,
761
800
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
762
- origin: { kind: 'owner', requestId },
801
+ origin: { kind: 'owner', requestId, displayText: text },
763
802
  });
764
803
  }
765
804
  catch (error) {
@@ -936,6 +975,149 @@ export class OwnerChannel {
936
975
  + `wire=${createHash('sha256').update(wireId).digest('hex').slice(0, 12)} `
937
976
  + `basis=${route.basis} chars=${Array.from(text).length} bytes=${Buffer.byteLength(text)}`);
938
977
  }
978
+ /**
979
+ * Caption and files are admitted as one relay transaction. The authenticated
980
+ * route and optional source wire are fixed before bytes are retrieved, and no
981
+ * outbound part is emitted until every file passes admission. A transport
982
+ * failure after emission starts is durably uncertain and never blind-retried;
983
+ * the managed agent receives one bounded NACK for the whole transaction.
984
+ */
985
+ async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
986
+ const captionWire = group.caption ? this.wireId(group.caption) : undefined;
987
+ const nackWire = captionWire ?? group.files[0].wireId;
988
+ const nackMessage = group.caption ?? { wire_id: nackWire };
989
+ let requestDir;
990
+ let recovery;
991
+ try {
992
+ if (!this.authorizationIntegrity().ok)
993
+ throw new Error('owner authorization state is corrupt; managed-agent relay is disabled');
994
+ const caption = group.caption ? this.safeRelayMessage(group.caption.text) : undefined;
995
+ const replyTo = this.managedAttachmentReplyWire(group, captionWire);
996
+ let route;
997
+ try {
998
+ route = replyTo
999
+ ? this.conversations.routeForWire(replyTo, this.effectiveOwners())
1000
+ : this.conversations.route(this.effectiveOwners());
1001
+ }
1002
+ catch (error) {
1003
+ throw new RelayUnroutableError(this.errorText(error));
1004
+ }
1005
+ if (!this.isEffectiveOwner(route.contact))
1006
+ throw new Error('selected attachment relay owner is no longer authorized');
1007
+ const contact = await this.routableContact(route);
1008
+ const rejection = !this.attachmentRecovery.integrity()
1009
+ ? 'attachment recovery state is unavailable'
1010
+ : validateAttachmentSelection(group.files, this.attachmentConfig);
1011
+ if (rejection)
1012
+ throw new Error(rejection);
1013
+ const transactionId = this.requestId(`managed-agent-attachment:${handledWireIds.slice().sort().join(':')}`);
1014
+ recovery = group.recovery ?? {
1015
+ id: transactionId, contact: agent, originWireId: captionWire ?? group.files[0].wireId,
1016
+ fileWireIds: group.files.map(file => file.wireId), createdAt: Date.now(),
1017
+ };
1018
+ if (!group.recovery)
1019
+ this.attachmentRecovery.add(recovery);
1020
+ requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
1021
+ const unread = group.files.filter(file => file.status === 'unread');
1022
+ const processed = group.files.filter(file => file.status !== 'unread');
1023
+ const retrieved = unread.length
1024
+ ? parseRetrievedAttachments(await this.client.callTool('get_files', {
1025
+ wire_ids: unread.map(file => file.wireId),
1026
+ }), unread)
1027
+ : [];
1028
+ for (const file of processed) {
1029
+ if (!group.recovery)
1030
+ throw new Error('unexpected processed attachment without recovery route');
1031
+ const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
1032
+ await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
1033
+ retrieved.push(await recoveredAttachment(file, recoveryPath));
1034
+ }
1035
+ const order = new Map(group.files.map((file, index) => [file.wireId, index]));
1036
+ retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
1037
+ const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
1038
+ const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
1039
+ const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
1040
+ try {
1041
+ if (caption)
1042
+ await this.send(contact, caption, replyTo);
1043
+ for (const file of admitted) {
1044
+ await this.client.callTool('send_file', {
1045
+ contact, path: file.path, filename: file.filename,
1046
+ ...(replyTo ? { reply_to_wire_id: replyTo } : {}),
1047
+ });
1048
+ }
1049
+ }
1050
+ catch {
1051
+ try {
1052
+ this.conversations.finishSend(sending.id, 'uncertain');
1053
+ }
1054
+ catch (error) {
1055
+ this.logError('managed-agent attachment uncertainty persist failed', error);
1056
+ }
1057
+ throw new Error('caption/file relay delivery outcome is uncertain; it was not retried');
1058
+ }
1059
+ this.conversations.finishSend(sending.id, 'delivered');
1060
+ for (const wire of handledWireIds)
1061
+ this.state.remember(wire);
1062
+ this.attachmentRecovery.remove(recovery.id);
1063
+ this.options.log(`[${this.options.role}] managed-agent attachment transaction relayed `
1064
+ + `wires=${handledWireIds.length} basis=${route.basis} files=${admitted.length} `
1065
+ + `bytes=${admitted.reduce((total, file) => total + file.size, 0)}`);
1066
+ return true;
1067
+ }
1068
+ catch (error) {
1069
+ if (error instanceof DuplicateSendError) {
1070
+ this.options.log(`[${this.options.role}] managed-agent attachment replay consumed`);
1071
+ for (const wire of handledWireIds)
1072
+ this.state.remember(wire);
1073
+ if (recovery)
1074
+ try {
1075
+ this.attachmentRecovery.remove(recovery.id);
1076
+ }
1077
+ catch { }
1078
+ return true;
1079
+ }
1080
+ if (error instanceof RelayUnroutableError) {
1081
+ this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
1082
+ + `transaction stays queued: ${this.errorText(error)}`);
1083
+ await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
1084
+ return false;
1085
+ }
1086
+ this.logError('managed-agent caption/file relay refused', error);
1087
+ await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
1088
+ // Rejection/admission failure and uncertain transport are terminal and
1089
+ // visible. Consuming every correlated wire prevents a later partial replay.
1090
+ for (const wire of handledWireIds)
1091
+ this.state.remember(wire);
1092
+ if (recovery)
1093
+ try {
1094
+ this.attachmentRecovery.remove(recovery.id);
1095
+ }
1096
+ catch { }
1097
+ return true;
1098
+ }
1099
+ finally {
1100
+ if (requestDir)
1101
+ await removeRequestDirectory(requestDir).catch(error => {
1102
+ this.logError('managed-agent attachment cleanup failed', error);
1103
+ });
1104
+ }
1105
+ }
1106
+ managedAttachmentReplyWire(group, captionWire) {
1107
+ const captionReply = group.caption?.reply_to?.wire_id;
1108
+ const candidates = new Set();
1109
+ if (captionReply)
1110
+ candidates.add(captionReply);
1111
+ for (const file of group.files) {
1112
+ const wire = file.replyTo?.wire_id;
1113
+ if (!wire || wire === captionWire)
1114
+ continue;
1115
+ candidates.add(wire);
1116
+ }
1117
+ if (candidates.size > 1)
1118
+ throw new Error('managed-agent caption/file group has conflicting owner reply wires');
1119
+ return candidates.values().next().value;
1120
+ }
939
1121
  /**
940
1122
  * One bounded NACK per wire: an unroutable or refused relay must be visible
941
1123
  * to the authenticated agent, while its deferred replays stay quiet. NACK
@@ -11,7 +11,7 @@ export declare class OwnerChannelState {
11
11
  has(wireId: string): boolean;
12
12
  remember(wireId: string): void;
13
13
  }
14
- export type OwnerConversationRouteBasis = 'last-inbound' | 'sole-owner';
14
+ export type OwnerConversationRouteBasis = 'last-inbound' | 'sole-owner' | 'source-wire';
15
15
  interface OwnerProactiveSend {
16
16
  id: string;
17
17
  contact: string;
@@ -28,6 +28,7 @@ interface OwnerProactiveSend {
28
28
  export declare class OwnerConversationState {
29
29
  private readonly path;
30
30
  private conversations;
31
+ private routes;
31
32
  private sends;
32
33
  private corruptReason?;
33
34
  constructor(path: string);
@@ -41,12 +42,17 @@ export declare class OwnerConversationState {
41
42
  contact: string;
42
43
  basis: OwnerConversationRouteBasis;
43
44
  };
45
+ routeForWire(wireId: string, effective: Set<string>): {
46
+ contact: string;
47
+ basis: OwnerConversationRouteBasis;
48
+ };
44
49
  beginSend(contact: string, digest: string, now?: number, minIntervalMs?: number, dedupe?: 'contact' | 'all'): OwnerProactiveSend;
45
50
  finishSend(id: string, status: 'delivered' | 'uncertain'): void;
46
51
  private mutate;
47
52
  private persist;
48
53
  private assertHealthy;
49
54
  private validConversation;
55
+ private validRoute;
50
56
  private validSend;
51
57
  }
52
58
  export type OwnerSource = 'baseline' | 'dynamic';
@@ -42,6 +42,7 @@ export class OwnerChannelState {
42
42
  }
43
43
  }
44
44
  const CONVERSATION_LIMIT = 64;
45
+ const WIRE_ROUTE_LIMIT = 512;
45
46
  const PROACTIVE_SEND_LIMIT = 256;
46
47
  const PROACTIVE_MIN_INTERVAL_MS = 30_000;
47
48
  const HEX_64_LOWER = /^[a-f0-9]{64}$/;
@@ -55,6 +56,7 @@ const CID = /^[A-Fa-f0-9]{64}$/;
55
56
  export class OwnerConversationState {
56
57
  path;
57
58
  conversations = [];
59
+ routes = [];
58
60
  sends = [];
59
61
  corruptReason;
60
62
  constructor(path) {
@@ -63,18 +65,29 @@ export class OwnerConversationState {
63
65
  return;
64
66
  try {
65
67
  const raw = JSON.parse(readFileSync(path, 'utf8'));
66
- if (raw.version !== 1 || !Array.isArray(raw.conversations) || !Array.isArray(raw.sends)
68
+ const legacy = raw.version === 1;
69
+ const routes = legacy
70
+ ? (raw.conversations ?? []).map(record => ({
71
+ contact: record.contact, wireId: record.lastInboundWireId, at: record.lastInboundAt,
72
+ }))
73
+ : raw.routes;
74
+ if (![1, 2].includes(raw.version ?? 0)
75
+ || !Array.isArray(raw.conversations) || !Array.isArray(routes) || !Array.isArray(raw.sends)
67
76
  || raw.conversations.length > CONVERSATION_LIMIT
77
+ || routes.length > WIRE_ROUTE_LIMIT
68
78
  || raw.sends.length > PROACTIVE_SEND_LIMIT
69
79
  || !raw.conversations.every(record => this.validConversation(record))
80
+ || !routes.every(route => this.validRoute(route))
70
81
  || !raw.sends.every(send => this.validSend(send)))
71
82
  throw new Error('invalid or unbounded conversation state');
72
83
  if (new Set(raw.conversations.map(record => record.contact)).size !== raw.conversations.length
84
+ || new Set(routes.map(route => route.wireId)).size !== routes.length
73
85
  || new Set(raw.sends.map(send => send.id)).size !== raw.sends.length)
74
86
  throw new Error('duplicate conversation state entry');
75
87
  this.conversations = raw.conversations.map(record => ({ ...record }));
88
+ this.routes = routes.map(route => ({ ...route }));
76
89
  this.sends = raw.sends.map(send => ({ ...send }));
77
- let recovered = false;
90
+ let recovered = legacy;
78
91
  for (const send of this.sends) {
79
92
  if (send.status === 'sending') {
80
93
  send.status = 'uncertain';
@@ -88,6 +101,7 @@ export class OwnerConversationState {
88
101
  catch {
89
102
  this.corruptReason = 'invalid persisted owner conversation state';
90
103
  this.conversations = [];
104
+ this.routes = [];
91
105
  this.sends = [];
92
106
  try {
93
107
  chmodSync(path, 0o600);
@@ -118,6 +132,9 @@ export class OwnerConversationState {
118
132
  throw new Error(`owner conversations are limited to ${CONVERSATION_LIMIT}`);
119
133
  this.conversations.push({ contact, lastInboundAt: acceptedAt, lastInboundWireId: wireId });
120
134
  }
135
+ this.routes = this.routes.filter(route => route.wireId !== wireId);
136
+ this.routes.push({ contact, wireId, at: acceptedAt });
137
+ this.routes = this.routes.slice(-WIRE_ROUTE_LIMIT);
121
138
  });
122
139
  }
123
140
  remove(contact) {
@@ -127,6 +144,7 @@ export class OwnerConversationState {
127
144
  return;
128
145
  this.mutate(() => {
129
146
  this.conversations = this.conversations.filter(record => canonicalCid(record.contact) !== canonical);
147
+ this.routes = this.routes.filter(route => canonicalCid(route.contact) !== canonical);
130
148
  });
131
149
  }
132
150
  route(effective) {
@@ -146,6 +164,14 @@ export class OwnerConversationState {
146
164
  return { contact: [...effective][0], basis: 'sole-owner' };
147
165
  throw new Error('no authenticated owner conversation route is available yet');
148
166
  }
167
+ routeForWire(wireId, effective) {
168
+ this.assertHealthy();
169
+ const route = [...this.routes].reverse().find(item => item.wireId === wireId);
170
+ const allowed = new Set([...effective].map(canonicalCid));
171
+ if (!route || !allowed.has(canonicalCid(route.contact)))
172
+ throw new Error('no authenticated owner route matches the source wire');
173
+ return { contact: route.contact, basis: 'source-wire' };
174
+ }
149
175
  beginSend(contact, digest, now = Date.now(), minIntervalMs = PROACTIVE_MIN_INTERVAL_MS, dedupe = 'contact') {
150
176
  this.assertHealthy();
151
177
  if (!CID.test(contact) || !HEX_64_LOWER.test(digest))
@@ -177,7 +203,9 @@ export class OwnerConversationState {
177
203
  this.mutate(() => { send.status = status; });
178
204
  }
179
205
  mutate(change) {
180
- const snapshot = JSON.stringify({ conversations: this.conversations, sends: this.sends });
206
+ const snapshot = JSON.stringify({
207
+ conversations: this.conversations, routes: this.routes, sends: this.sends,
208
+ });
181
209
  change();
182
210
  try {
183
211
  this.persist();
@@ -185,13 +213,14 @@ export class OwnerConversationState {
185
213
  catch (error) {
186
214
  const old = JSON.parse(snapshot);
187
215
  this.conversations = old.conversations;
216
+ this.routes = old.routes;
188
217
  this.sends = old.sends;
189
218
  throw error;
190
219
  }
191
220
  }
192
221
  persist() {
193
222
  replaceFileAtomically(this.path, JSON.stringify({
194
- version: 1, conversations: this.conversations, sends: this.sends,
223
+ version: 2, conversations: this.conversations, routes: this.routes, sends: this.sends,
195
224
  }) + '\n', 0o600);
196
225
  chmodSync(this.path, 0o600);
197
226
  }
@@ -207,6 +236,14 @@ export class OwnerConversationState {
207
236
  && record.lastInboundAt >= 0 && typeof record.lastInboundWireId === 'string'
208
237
  && record.lastInboundWireId.length > 0 && record.lastInboundWireId.length <= 1_024;
209
238
  }
239
+ validRoute(value) {
240
+ if (!value || typeof value !== 'object')
241
+ return false;
242
+ const route = value;
243
+ return CID.test(route.contact) && typeof route.wireId === 'string'
244
+ && route.wireId.length > 0 && route.wireId.length <= 1_024
245
+ && Number.isSafeInteger(route.at) && route.at >= 0;
246
+ }
210
247
  validSend(value) {
211
248
  if (!value || typeof value !== 'object')
212
249
  return false;