@ours.network/fleet 1.1.0-nightly.12 → 1.1.0-nightly.13

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.
@@ -1,3 +1,4 @@
1
+ import { type AgentLaunchConfiguration } from './lifecycle-summary.js';
1
2
  export type FleetCommandDecision = 'allow' | 'deny' | 'unsupported';
2
3
  export type FleetCommandOutcomeClass = 'success' | 'validation' | 'denied' | 'runtime' | 'timeout' | 'proxy' | 'delivery';
3
4
  export declare class FleetCliExit extends Error {
@@ -21,6 +22,7 @@ export type FleetAuditPresentation = {
21
22
  parent: string;
22
23
  actionId: string;
23
24
  inherited: string[];
25
+ configuration?: AgentLaunchConfiguration;
24
26
  } | {
25
27
  kind: 'task';
26
28
  operation: 'create' | 'start' | 'work' | 'block' | 'unblock' | 'review' | 'done' | 'cancel' | 'finish' | 'delete' | 'settling';
@@ -38,6 +40,7 @@ export type FleetAuditPresentation = {
38
40
  brain?: string;
39
41
  role: string;
40
42
  permissions?: string;
43
+ configuration?: AgentLaunchConfiguration;
41
44
  }>;
42
45
  } | {
43
46
  kind: 'room';
@@ -56,6 +59,7 @@ export type FleetAuditPresentation = {
56
59
  brain?: string;
57
60
  role: string;
58
61
  permissions?: string;
62
+ configuration?: AgentLaunchConfiguration;
59
63
  }>;
60
64
  } | {
61
65
  kind: 'lifecycle_failure';
@@ -1,7 +1,10 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { Buffer } from 'node:buffer';
3
4
  import { replaceFileAtomically } from './atomic-file.js';
4
5
  import { isSensitiveConfigKey } from './sensitive-config.js';
6
+ import { mandatoryConfigurationFits, renderAgentConfiguration, } from './lifecycle-summary.js';
7
+ import { MARKDOWN_MAX_BYTES, MARKDOWN_MAX_CODE_POINTS, markdownCode, markdownProse, } from './rooms-tasks/markdown.js';
5
8
  export class FleetCliExit extends Error {
6
9
  exitCode;
7
10
  outcomeClass;
@@ -330,10 +333,71 @@ export function validateFleetAuditFinish(value) {
330
333
  || input.presentations.length > 128 || !input.presentations.every(validPresentation))))
331
334
  throw new Error('invalid fleet audit finish fields');
332
335
  }
336
+ const APPROVAL_MODES = new Set(['ask', 'auto', 'allow', 'deny']);
337
+ const FILESYSTEM_MODES = new Set(['read-only', 'workspace', 'unrestricted']);
338
+ const UNATTENDED_MODES = new Set(['deny', 'wait']);
339
+ const FLEET_PERMISSION_MODES = new Set(['ask', 'auto', 'allow']);
340
+ const PRESENTATION_TEXT = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
341
+ /**
342
+ * Exact-key validation of a current-version launch configuration. A partial
343
+ * v1 configuration is rejected; a legacy presentation simply omits the field.
344
+ */
345
+ function validConfiguration(value) {
346
+ if (!value || typeof value !== 'object' || Array.isArray(value))
347
+ return false;
348
+ const c = value;
349
+ const text = (v, max = 256) => typeof v === 'string' && v.length <= max
350
+ && !PRESENTATION_TEXT.test(v);
351
+ const exact = (record, keys) => Object.keys(record).every(key => keys.includes(key));
352
+ const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
353
+ const origin = (v) => {
354
+ if (!record(v))
355
+ return false;
356
+ if (v.kind === 'named')
357
+ return exact(v, ['kind', 'ref']) && text(v.ref, 160);
358
+ if (v.kind === 'inline')
359
+ return exact(v, ['kind', 'fingerprint'])
360
+ && (v.fingerprint === undefined || (typeof v.fingerprint === 'string' && /^[a-f0-9]{12}$/.test(v.fingerprint)));
361
+ return v.kind === 'unknown' && exact(v, ['kind']);
362
+ };
363
+ const count = (v) => v === undefined
364
+ || (Number.isSafeInteger(v) && Number(v) >= 0 && Number(v) <= 4_096);
365
+ return exact(c, ['version', 'template', 'role', 'brain', 'harness', 'session', 'model',
366
+ 'effort', 'mission', 'approval', 'filesystem', 'unattended', 'permissionMode', 'monitor', 'isolation'])
367
+ && c.version === 1 && origin(c.role) && origin(c.brain)
368
+ && text(c.harness, 64) && c.session === 'acp'
369
+ && (c.model === null || text(c.model, 160))
370
+ && (c.template === undefined || text(c.template, 160))
371
+ && (c.effort === undefined || text(c.effort, 32))
372
+ // The builder caps the mission label at 80 code points; enforce the same
373
+ // invariant here so the per-line budget proof holds for wire input too.
374
+ && (c.mission === undefined || (text(c.mission) && Array.from(String(c.mission)).length <= 80))
375
+ && APPROVAL_MODES.has(String(c.approval))
376
+ && FILESYSTEM_MODES.has(String(c.filesystem))
377
+ && UNATTENDED_MODES.has(String(c.unattended))
378
+ && record(c.permissionMode)
379
+ && exact(c.permissionMode, ['fleetMode', 'nativeMode'])
380
+ && FLEET_PERMISSION_MODES.has(String(c.permissionMode.fleetMode))
381
+ && text(c.permissionMode.nativeMode, 160)
382
+ && record(c.monitor) && exact(c.monitor, ['mode', 'interrupt'])
383
+ && ['fleet', 'native'].includes(String(c.monitor.mode))
384
+ && (typeof c.monitor.interrupt === 'boolean' || c.monitor.interrupt === 'after_tool')
385
+ && (c.isolation === undefined || (record(c.isolation)
386
+ && exact(c.isolation, ['requested', 'on_unavailable', 'network', 'read_mounts', 'write_mounts'])
387
+ && ['auto', 'bubblewrap', 'podman', 'none'].includes(String(c.isolation.requested))
388
+ && (c.isolation.on_unavailable === undefined || ['warn', 'strict'].includes(String(c.isolation.on_unavailable)))
389
+ && (c.isolation.network === undefined || ['broker', 'deny', 'allow', 'allowlist'].includes(String(c.isolation.network)))
390
+ && count(c.isolation.read_mounts) && count(c.isolation.write_mounts)))
391
+ // Escaping and code-fence growth can push a per-field-valid configuration
392
+ // past the line budget; a v1 configuration whose complete mandatory
393
+ // rendering cannot fit is invalid, never silently trimmed.
394
+ && mandatoryConfigurationFits(c);
395
+ }
333
396
  function validPresentation(value) {
334
397
  if (!value || typeof value !== 'object' || Array.isArray(value))
335
398
  return false;
336
399
  const p = value;
400
+ const configured = (record) => record.configuration === undefined || validConfiguration(record.configuration);
337
401
  const safe = (v) => typeof v === 'string' && SAFE_RESOURCE_ID.test(v);
338
402
  const text = (v) => typeof v === 'string' && v.length <= 256
339
403
  && !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(v);
@@ -342,11 +406,11 @@ function validPresentation(value) {
342
406
  && allowed[p.operation]?.includes(`${String(p.previousState ?? 'none')}→${p.newState}`);
343
407
  if (p.kind === 'agent_started')
344
408
  return exact(p, ['kind', 'eventId', 'id', 'name', 'lifetime', 'brain', 'role',
345
- 'harness', 'session', 'model', 'permissions', 'parent', 'actionId', 'inherited'])
409
+ 'harness', 'session', 'model', 'permissions', 'parent', 'actionId', 'inherited', 'configuration'])
346
410
  && safe(p.eventId) && safe(p.id) && safe(p.name) && ['permanent', 'temporary'].includes(String(p.lifetime))
347
411
  && text(p.brain) && text(p.role) && safe(p.harness) && p.session === 'acp' && safe(p.parent) && safe(p.actionId)
348
412
  && (p.model === undefined || text(p.model)) && (p.permissions === undefined || text(p.permissions))
349
- && Array.isArray(p.inherited) && p.inherited.every(text);
413
+ && Array.isArray(p.inherited) && p.inherited.every(text) && configured(p);
350
414
  if (p.kind === 'task')
351
415
  return exact(p, ['kind', 'operation', 'eventId', 'id', 'title', 'previousState',
352
416
  'newState', 'template', 'roomId', 'revision', 'list', 'agents'])
@@ -358,18 +422,24 @@ function validPresentation(value) {
358
422
  unblock: ['backlog→backlog', 'provisioning→provisioning', 'active→active', 'review→review'],
359
423
  review: ['active→review'], done: ['review→done'], finish: ['active→done', 'review→done'],
360
424
  cancel: ['backlog→cancelled', 'provisioning→cancelled', 'active→cancelled', 'review→cancelled'],
361
- delete: ['done→deleted'], settling: ['active→active', 'review→review', 'backlog→backlog',
425
+ delete: [
426
+ 'backlog→deleting', 'provisioning→deleting', 'active→deleting', 'review→deleting',
427
+ 'done→deleting', 'cancelled→deleting', 'failed→deleting',
428
+ 'backlog→deleted', 'provisioning→deleted', 'active→deleted', 'review→deleted',
429
+ 'done→deleted', 'cancelled→deleted', 'failed→deleted',
430
+ ], settling: ['active→active', 'review→review', 'backlog→backlog',
362
431
  'provisioning→provisioning'] })
363
432
  && (p.title === undefined || text(p.title)) && (p.previousState === undefined || safe(p.previousState))
364
433
  && (p.template === undefined || text(p.template)) && (p.roomId === undefined || safe(p.roomId))
365
434
  && (p.list === undefined || text(p.list)) && (p.revision === undefined || text(p.revision))
366
435
  && Array.isArray(p.agents) && p.agents.length <= 64
367
436
  && p.agents.every(a => a && typeof a === 'object'
368
- && exact(a, ['name', 'brain', 'role', 'permissions'])
437
+ && exact(a, ['name', 'brain', 'role', 'permissions', 'configuration'])
369
438
  && safe(a.name)
370
439
  && text(a.role)
371
440
  && (a.brain === undefined || text(a.brain))
372
- && (a.permissions === undefined || text(a.permissions)));
441
+ && (a.permissions === undefined || text(a.permissions))
442
+ && configured(a));
373
443
  if (p.kind === 'room')
374
444
  return exact(p, ['kind', 'operation', 'eventId', 'id', 'previousState', 'newState',
375
445
  'revision', 'name', 'template', 'taskId', 'participants'])
@@ -383,12 +453,13 @@ function validPresentation(value) {
383
453
  && (p.taskId === undefined || safe(p.taskId)) && (p.revision === undefined || text(p.revision))
384
454
  && Array.isArray(p.participants) && p.participants.length <= 64
385
455
  && p.participants.every(a => a && typeof a === 'object'
386
- && exact(a, ['name', 'id', 'brain', 'role', 'permissions'])
456
+ && exact(a, ['name', 'id', 'brain', 'role', 'permissions', 'configuration'])
387
457
  && safe(a.name)
388
458
  && text(a.role)
389
459
  && (a.id === undefined || safe(a.id))
390
460
  && (a.brain === undefined || text(a.brain))
391
- && (a.permissions === undefined || text(a.permissions)));
461
+ && (a.permissions === undefined || text(a.permissions))
462
+ && configured(a));
392
463
  if (p.kind === 'lifecycle_failure')
393
464
  return exact(p, ['kind', 'eventId', 'resource', 'id', 'state', 'category'])
394
465
  && ['Agent', 'Task', 'Room'].includes(String(p.resource))
@@ -396,6 +467,35 @@ function validPresentation(value) {
396
467
  'settlement_pending', 'cleanup_failed', 'cleanup_pending'].includes(String(p.category));
397
468
  return false;
398
469
  }
470
+ const messageCodePoints = (value) => Array.from(value).length;
471
+ const messageBytes = (value) => Buffer.byteLength(value, 'utf8');
472
+ const withinMessageBounds = (value) => messageCodePoints(value) <= MARKDOWN_MAX_CODE_POINTS && messageBytes(value) <= MARKDOWN_MAX_BYTES;
473
+ /**
474
+ * Append per-agent lines to a bounded message. Lines are admitted whole, in
475
+ * the given (deterministic) order; once the shared Markdown bounds would be
476
+ * exceeded the remaining agents collapse into an accurate omission note. The
477
+ * first line is admitted whenever it fits the absolute bounds so an oversized
478
+ * roster still shows at least one complete agent summary.
479
+ */
480
+ function appendAgentLines(header, heading, lines) {
481
+ if (!lines.length)
482
+ return header;
483
+ let message = `${header}\n${heading}:`;
484
+ const note = (count) => `\n…and ${count} more agent${count === 1 ? '' : 's'} omitted.`;
485
+ for (const [index, line] of lines.entries()) {
486
+ const candidate = `${message}\n- ${line}`;
487
+ const remaining = lines.length - index - 1;
488
+ // A candidate is admitted only when it fits together with the note for
489
+ // every agent still pending, so each stop-return below re-states a bound
490
+ // that was verified when the current message was admitted.
491
+ if (!withinMessageBounds(remaining ? `${candidate}${note(remaining)}` : candidate)) {
492
+ const stopped = `${message}${note(lines.length - index)}`;
493
+ return withinMessageBounds(stopped) ? stopped : `${header}${note(lines.length)}`;
494
+ }
495
+ message = candidate;
496
+ }
497
+ return message;
498
+ }
399
499
  /** Compact Owner presentation. It intentionally has no command, argv, environment, or correlation data. */
400
500
  export function renderFleetLifecycleEvent(value) {
401
501
  if (value.kind === 'lifecycle_failure') {
@@ -413,25 +513,29 @@ export function renderFleetLifecycleEvent(value) {
413
513
  + `state ${value.state}. Action: ${actions[value.category]}`;
414
514
  }
415
515
  if (value.kind === 'agent_started') {
416
- const permission = value.permissions ? `; permissions ${value.permissions}` : '';
516
+ const summary = renderAgentConfiguration(value.configuration, {
517
+ role: value.role, brain: value.brain, permissions: value.permissions,
518
+ });
417
519
  return `🧑‍💻 ${value.parent} spawned ${value.lifetime} Agent ${value.name} (${value.id}) — ready. `
418
- + `Brain ${value.brain ?? 'unresolved'}; Role ${value.role ?? 'unresolved'}${permission}.`;
520
+ + `${summary}.`;
419
521
  }
420
522
  if (value.kind === 'task') {
421
- const title = value.title ? ` “${value.title}”` : '';
422
- const context = [value.list ? `List ${value.list}` : undefined,
423
- value.template ? `template ${value.template}` : undefined,
523
+ const title = value.title ? ` “${markdownProse(value.title)}”` : '';
524
+ const context = [value.list ? `List ${markdownCode(value.list)}` : undefined,
525
+ value.template ? `template ${markdownCode(value.template)}` : undefined,
424
526
  value.roomId ? `Room ${value.roomId}` : undefined].filter(Boolean).join('; ');
425
- const agents = value.agents.length ? ` Agents: ${value.agents.map(agent => `${agent.name} [Brain ${agent.brain ?? 'unresolved'}; Role ${agent.role}`
426
- + `${agent.permissions ? `; permissions ${agent.permissions}` : ''}]`).join('; ')}.` : '';
427
- return `📋 Task${title} (${value.id}) ${value.operation}: `
428
- + `${value.previousState ?? 'none'} → ${value.newState}.${context ? ` ${context}.` : ''}${agents}`;
527
+ const header = `📋 Task${title} (${value.id}) ${value.operation}: `
528
+ + `${value.previousState ?? 'none'} → ${value.newState}.${context ? ` ${context}.` : ''}`;
529
+ return appendAgentLines(header, 'Agents', value.agents.map(agent => `${markdownCode(agent.name)}: ${renderAgentConfiguration(agent.configuration, {
530
+ role: agent.role, brain: agent.brain, permissions: agent.permissions,
531
+ })}`));
429
532
  }
430
- const context = [value.template ? `template ${value.template}` : undefined,
533
+ const context = [value.template ? `template ${markdownCode(value.template)}` : undefined,
431
534
  value.taskId ? `Task ${value.taskId}` : undefined].filter(Boolean).join('; ');
432
- const participants = value.participants.length ? ` Participants: ${value.participants.map(participant => `${participant.name}${participant.id ? ` (${participant.id})` : ''} [Role ${participant.role}`
433
- + `${participant.brain ? `; Brain ${participant.brain}` : ''}`
434
- + `${participant.permissions ? `; permissions ${participant.permissions}` : ''}]`).join('; ')}.` : '';
435
- return `🏠 Room${value.name ? ` “${value.name}”` : ''} (${value.id}) ${value.operation}: `
436
- + `${value.previousState ?? 'none'} → ${value.newState}.${context ? ` ${context}.` : ''}${participants}`;
535
+ const header = `🏠 Room${value.name ? ` “${markdownProse(value.name)}”` : ''} (${value.id}) ${value.operation}: `
536
+ + `${value.previousState ?? 'none'} → ${value.newState}.${context ? ` ${context}.` : ''}`;
537
+ return appendAgentLines(header, 'Participants', value.participants.map(participant => `${markdownCode(participant.name)}${participant.id ? ` (${participant.id})` : ''}: `
538
+ + renderAgentConfiguration(participant.configuration, {
539
+ role: participant.role, brain: participant.brain, permissions: participant.permissions,
540
+ })));
437
541
  }
@@ -1,4 +1,5 @@
1
1
  import type { MonitorConfig, ResolvedRole } from './config.js';
2
+ import type { AgentLaunchConfiguration } from './lifecycle-summary.js';
2
3
  import type { SpawnOpts } from './spawn.js';
3
4
  /** Present only inside a managed role process. The CLI treats it as a routing hint, not authority. */
4
5
  export declare const FLEET_PROXY_STATE_DIR_ENV = "OURS_FLEET_PROXY_STATE_DIR";
@@ -21,6 +22,8 @@ export interface ManagedFleetSpawnResult {
21
22
  creationActionId: string;
22
23
  brainSummary: string;
23
24
  roleSummary: string;
25
+ /** Launch configuration captured from the exact resolved role; absent only from pre-upgrade daemons. */
26
+ configuration?: AgentLaunchConfiguration;
24
27
  }
25
28
  /**
26
29
  * Fill only omitted spawn settings from the live caller. Explicit agent choices
@@ -0,0 +1,118 @@
1
+ import type { AgentSelection, ApprovalMode, FilesystemMode, FleetPermissionMode, MonitorInterrupt, MonitorMode, ResolvedRole, SessionBackendId, UnattendedMode } from './config.js';
2
+ import type { IsolationBackendId, NetworkMode, OnUnavailable } from './isolation/types.js';
3
+ /**
4
+ * Operator-facing launch configuration captured at the launch boundary from
5
+ * the exact ResolvedRole that was spawned. It is the single source for every
6
+ * Fleet lifecycle/report surface (CLI and owner-channel/Messenger).
7
+ *
8
+ * Security boundary: this object is a strict whitelist. It must never carry
9
+ * env, cwd or any local path, harness_options, session_options, owner_channel,
10
+ * or auth_proxy content. Hashes are secondary provenance only, never the
11
+ * primary description.
12
+ */
13
+ export interface AgentLaunchConfiguration {
14
+ version: 1;
15
+ /** Agent template / launch definition label when one was selected. */
16
+ template?: string;
17
+ role: SelectionOrigin;
18
+ brain: SelectionOrigin;
19
+ harness: string;
20
+ session: SessionBackendId;
21
+ /**
22
+ * Model observed effective at launch. `null` reports runtime behavior — the
23
+ * harness ran its own default — not whether the author wrote `model: null`
24
+ * or omitted the field; effectiveRoleModel cannot distinguish those.
25
+ */
26
+ model: string | null;
27
+ effort?: string;
28
+ /** Concise first-line mission label (or Cowork-role fallback), never the full mission. */
29
+ mission?: string;
30
+ approval: ApprovalMode;
31
+ filesystem: FilesystemMode;
32
+ unattended: UnattendedMode;
33
+ /** Portable policy plus the exact native runtime mode after harness_options. */
34
+ permissionMode: {
35
+ fleetMode: FleetPermissionMode;
36
+ nativeMode: string;
37
+ };
38
+ monitor: {
39
+ mode: MonitorMode;
40
+ interrupt: MonitorInterrupt;
41
+ };
42
+ /** Requested isolation policy only — never resolved runtime facts or host paths. */
43
+ isolation?: {
44
+ requested: IsolationBackendId;
45
+ on_unavailable?: OnUnavailable;
46
+ network?: NetworkMode;
47
+ read_mounts?: number;
48
+ write_mounts?: number;
49
+ };
50
+ }
51
+ /** Where a Brain/Role selection came from; labels are human, hashes secondary. */
52
+ export type SelectionOrigin = {
53
+ kind: 'named';
54
+ ref: string;
55
+ } | {
56
+ kind: 'inline';
57
+ fingerprint?: string;
58
+ } | {
59
+ kind: 'unknown';
60
+ };
61
+ export declare const MISSION_LABEL_MAX = 80;
62
+ /**
63
+ * The inline fingerprint is always computed here from the canonical inline
64
+ * body; provenance can never be an arbitrary caller string.
65
+ */
66
+ export declare function selectionOrigin(selection: AgentSelection | undefined): SelectionOrigin;
67
+ export declare function missionLabel(mission: string | undefined): string | undefined;
68
+ /**
69
+ * Build the presentation from the exact resolved launch state. `origins`
70
+ * carries the authoring-time selections and template label because a merged
71
+ * ResolvedRole no longer distinguishes preset references from inline bodies.
72
+ * `permissionMode` is required at capture time: every supported harness
73
+ * adapter reports it, and the managed-spawn receiver gets it over the wire
74
+ * and must never re-resolve mutable configuration.
75
+ */
76
+ export declare function summarizeResolvedLaunch(role: ResolvedRole, origins: {
77
+ role: SelectionOrigin;
78
+ brain: SelectionOrigin;
79
+ template?: string;
80
+ permissionMode: {
81
+ fleetMode: FleetPermissionMode;
82
+ nativeMode: string;
83
+ };
84
+ /** Shown when the resolved role has no mission text (e.g. Cowork role name). */
85
+ missionFallback?: string;
86
+ }): AgentLaunchConfiguration;
87
+ /**
88
+ * Component budget for one rendered agent line. The complete mandatory
89
+ * rendering must always fit — that is a validity condition of a current-v1
90
+ * configuration, enforced by mandatoryConfigurationFits at the builder AND
91
+ * the wire boundary — while optional components (monitor, isolation) are
92
+ * dropped whole with a visible `…` marker past this budget.
93
+ */
94
+ export declare const AGENT_LINE_MAX_CODE_POINTS = 1500;
95
+ export declare const AGENT_LINE_MAX_BYTES = 5000;
96
+ /**
97
+ * Validity condition of a current-v1 configuration: its complete mandatory
98
+ * rendering fits the per-line budget with the optional-omission suffix
99
+ * reserved. Escaping and code-fence growth (a value made of backticks can
100
+ * more than triple its code span) make this depend on rendered size, not raw
101
+ * field lengths, so both summarizeResolvedLaunch and the wire validator call
102
+ * this instead of trusting per-field caps.
103
+ */
104
+ export declare function mandatoryConfigurationFits(configuration: AgentLaunchConfiguration): boolean;
105
+ /**
106
+ * Render one agent's configuration as a single escaped Markdown line segment.
107
+ * This is the only escaping boundary: callers pass raw values and must not
108
+ * escape the result again (it is safe as a markdownItems entry and inside
109
+ * owner-channel lifecycle messages). Every mandatory component always
110
+ * renders — mandatoryConfigurationFits guarantees the fit for every produced
111
+ * or wire-accepted configuration; only optional components may be dropped,
112
+ * whole (never splitting a Markdown span), with a visible `…`.
113
+ */
114
+ export declare function renderAgentConfiguration(configuration: AgentLaunchConfiguration | undefined, fallback?: {
115
+ role?: string;
116
+ brain?: string;
117
+ permissions?: string;
118
+ }): string;
@@ -0,0 +1,161 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Buffer } from 'node:buffer';
3
+ import { canonicalJson } from './canonical-json.js';
4
+ import { effectiveRoleModel } from './model-env.js';
5
+ import { markdownCode, markdownProse } from './rooms-tasks/markdown.js';
6
+ const FINGERPRINT_HEX = 12;
7
+ export const MISSION_LABEL_MAX = 80;
8
+ /**
9
+ * The inline fingerprint is always computed here from the canonical inline
10
+ * body; provenance can never be an arbitrary caller string.
11
+ */
12
+ export function selectionOrigin(selection) {
13
+ if (!selection)
14
+ return { kind: 'unknown' };
15
+ if ('ref' in selection)
16
+ return { kind: 'named', ref: selection.ref };
17
+ const fingerprint = createHash('sha256').update(canonicalJson(selection.inline)).digest('hex');
18
+ return { kind: 'inline', fingerprint: fingerprint.slice(0, FINGERPRINT_HEX) };
19
+ }
20
+ export function missionLabel(mission) {
21
+ const line = mission?.trim().split('\n', 1)[0]?.trim();
22
+ if (!line)
23
+ return undefined;
24
+ const points = Array.from(line);
25
+ return points.length > MISSION_LABEL_MAX
26
+ ? `${points.slice(0, MISSION_LABEL_MAX - 1).join('')}…` : line;
27
+ }
28
+ /**
29
+ * Build the presentation from the exact resolved launch state. `origins`
30
+ * carries the authoring-time selections and template label because a merged
31
+ * ResolvedRole no longer distinguishes preset references from inline bodies.
32
+ * `permissionMode` is required at capture time: every supported harness
33
+ * adapter reports it, and the managed-spawn receiver gets it over the wire
34
+ * and must never re-resolve mutable configuration.
35
+ */
36
+ export function summarizeResolvedLaunch(role, origins) {
37
+ const mission = missionLabel(role.mission) ?? missionLabel(origins.missionFallback);
38
+ const fs = role.isolation?.fs;
39
+ const configuration = {
40
+ version: 1,
41
+ ...(origins.template ? { template: origins.template } : {}),
42
+ role: origins.role,
43
+ brain: origins.brain,
44
+ harness: role.harness,
45
+ session: role.session,
46
+ model: effectiveRoleModel(role) ?? null,
47
+ ...(role.effort ? { effort: role.effort } : {}),
48
+ ...(mission ? { mission } : {}),
49
+ approval: role.permissions.approval,
50
+ filesystem: role.permissions.filesystem,
51
+ unattended: role.permissions.unattended,
52
+ permissionMode: origins.permissionMode,
53
+ monitor: { mode: role.monitor.mode, interrupt: role.monitor.interrupt },
54
+ ...(role.isolation?.backend ? { isolation: {
55
+ requested: role.isolation.backend,
56
+ ...(role.isolation.on_unavailable ? { on_unavailable: role.isolation.on_unavailable } : {}),
57
+ ...(role.isolation.network ? { network: role.isolation.network } : {}),
58
+ ...(fs?.read?.length ? { read_mounts: fs.read.length } : {}),
59
+ ...(fs?.write?.length ? { write_mounts: fs.write.length } : {}),
60
+ } } : {}),
61
+ };
62
+ // A configuration whose complete mandatory rendering cannot fit must never
63
+ // be produced; real resolved values sit far below the budget, so this is a
64
+ // capture defect, not an expected path.
65
+ if (!mandatoryConfigurationFits(configuration))
66
+ throw new Error('agent launch presentation exceeds the mandatory rendering budget');
67
+ return configuration;
68
+ }
69
+ /**
70
+ * Component budget for one rendered agent line. The complete mandatory
71
+ * rendering must always fit — that is a validity condition of a current-v1
72
+ * configuration, enforced by mandatoryConfigurationFits at the builder AND
73
+ * the wire boundary — while optional components (monitor, isolation) are
74
+ * dropped whole with a visible `…` marker past this budget.
75
+ */
76
+ export const AGENT_LINE_MAX_CODE_POINTS = 1_500;
77
+ export const AGENT_LINE_MAX_BYTES = 5_000;
78
+ const codePoints = (value) => Array.from(value).length;
79
+ const utf8 = (value) => Buffer.byteLength(value, 'utf8');
80
+ const OPTIONAL_OMISSION_SUFFIX = '; …';
81
+ function selectionComponent(kind, value) {
82
+ if (value.kind === 'named')
83
+ return `${kind} preset ${markdownCode(value.ref)}`;
84
+ if (value.kind === 'inline')
85
+ return `inline ${kind}${value.fingerprint ? ` (def ${markdownCode(value.fingerprint)})` : ''}`;
86
+ return `${kind} unknown`;
87
+ }
88
+ /**
89
+ * The complete mandatory rendering: template, origins, mission, harness,
90
+ * model, effort, portable policy triple, and fleet/native permission mode.
91
+ * These must never be omitted from an operator-facing line.
92
+ */
93
+ function mandatoryComponents(configuration) {
94
+ return [
95
+ configuration.template ? `template ${markdownCode(configuration.template)}` : undefined,
96
+ selectionComponent('Role', configuration.role),
97
+ configuration.mission ? `mission “${markdownProse(configuration.mission)}”` : undefined,
98
+ selectionComponent('Brain', configuration.brain),
99
+ `harness ${markdownCode(configuration.harness)}`,
100
+ `model ${configuration.model === null ? 'harness-default' : markdownCode(configuration.model)}`,
101
+ configuration.effort ? `effort ${markdownProse(configuration.effort)}` : undefined,
102
+ `approval=${configuration.approval}, filesystem=${configuration.filesystem}, `
103
+ + `unattended=${configuration.unattended}`,
104
+ `mode ${configuration.permissionMode.fleetMode}/${markdownProse(configuration.permissionMode.nativeMode)}`,
105
+ ].filter((part) => Boolean(part));
106
+ }
107
+ function optionalComponents(configuration) {
108
+ return [
109
+ `monitor ${configuration.monitor.mode}/${String(configuration.monitor.interrupt)}`,
110
+ configuration.isolation
111
+ ? `isolation requested ${configuration.isolation.requested}`
112
+ + `${configuration.isolation.network ? `, net ${configuration.isolation.network}` : ''}`
113
+ + `${configuration.isolation.on_unavailable ? `, on-unavailable ${configuration.isolation.on_unavailable}` : ''}`
114
+ + `${configuration.isolation.read_mounts || configuration.isolation.write_mounts
115
+ ? `, mounts +${configuration.isolation.read_mounts ?? 0}ro/+${configuration.isolation.write_mounts ?? 0}rw` : ''}`
116
+ : undefined,
117
+ ].filter((part) => Boolean(part));
118
+ }
119
+ /**
120
+ * Validity condition of a current-v1 configuration: its complete mandatory
121
+ * rendering fits the per-line budget with the optional-omission suffix
122
+ * reserved. Escaping and code-fence growth (a value made of backticks can
123
+ * more than triple its code span) make this depend on rendered size, not raw
124
+ * field lengths, so both summarizeResolvedLaunch and the wire validator call
125
+ * this instead of trusting per-field caps.
126
+ */
127
+ export function mandatoryConfigurationFits(configuration) {
128
+ const reserved = `${mandatoryComponents(configuration).join('; ')}${OPTIONAL_OMISSION_SUFFIX}`;
129
+ return codePoints(reserved) <= AGENT_LINE_MAX_CODE_POINTS && utf8(reserved) <= AGENT_LINE_MAX_BYTES;
130
+ }
131
+ /**
132
+ * Render one agent's configuration as a single escaped Markdown line segment.
133
+ * This is the only escaping boundary: callers pass raw values and must not
134
+ * escape the result again (it is safe as a markdownItems entry and inside
135
+ * owner-channel lifecycle messages). Every mandatory component always
136
+ * renders — mandatoryConfigurationFits guarantees the fit for every produced
137
+ * or wire-accepted configuration; only optional components may be dropped,
138
+ * whole (never splitting a Markdown span), with a visible `…`.
139
+ */
140
+ export function renderAgentConfiguration(configuration, fallback) {
141
+ if (!configuration) {
142
+ const labels = [
143
+ fallback?.role ? `Role ${markdownProse(fallback.role)}` : undefined,
144
+ fallback?.brain ? `Brain ${markdownProse(fallback.brain)}` : undefined,
145
+ fallback?.permissions ? `permissions ${markdownProse(fallback.permissions)}` : undefined,
146
+ ].filter(Boolean).join('; ');
147
+ return `legacy launch; resolved details unavailable${labels ? ` (${labels})` : ''}`;
148
+ }
149
+ let line = mandatoryComponents(configuration).join('; ');
150
+ let omitted = false;
151
+ for (const part of optionalComponents(configuration)) {
152
+ const next = `${line}; ${part}`;
153
+ if (codePoints(`${next}${OPTIONAL_OMISSION_SUFFIX}`) > AGENT_LINE_MAX_CODE_POINTS
154
+ || utf8(`${next}${OPTIONAL_OMISSION_SUFFIX}`) > AGENT_LINE_MAX_BYTES) {
155
+ omitted = true;
156
+ continue;
157
+ }
158
+ line = next;
159
+ }
160
+ return omitted ? `${line}${OPTIONAL_OMISSION_SUFFIX}` : line;
161
+ }
@@ -284,6 +284,8 @@ export declare class OwnerChannel implements OwnerChannelHandle {
284
284
  private recoverRoomFromOwner;
285
285
  /** Carry a task terminal request through a worker that survives this role. */
286
286
  private recoverTaskFromOwner;
287
+ /** Accept a permanent any-state deletion, acknowledge it, then hand cleanup to a durable worker. */
288
+ private deleteTaskFromOwner;
287
289
  private terminalTaskFromOwner;
288
290
  /** Code-point-safe tail of the worklog, or undefined when there is none. */
289
291
  private readWorklogTail;
@@ -270,7 +270,8 @@ export class OwnerChannel {
270
270
  lifetime: event.lifetime, brain: event.brainSummary, role: event.roleSummary,
271
271
  harness: event.harness, session: event.session, model: event.model,
272
272
  permissions: permission, parent: event.caller, actionId: event.creationActionId,
273
- inherited: event.inherited }), `fleet-spawn\0${event.creationActionId}`, 0);
273
+ inherited: event.inherited,
274
+ ...(event.configuration ? { configuration: event.configuration } : {}) }), `fleet-spawn\0${event.creationActionId}`, 0);
274
275
  });
275
276
  }
276
277
  beginFleetCommandAudit(requestId, argv) {
@@ -1269,9 +1270,7 @@ export class OwnerChannel {
1269
1270
  reviewTask: taskId => new TaskRoomApplicationService(this.options.configPath).reviewTask({
1270
1271
  actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1271
1272
  }),
1272
- deleteTask: taskId => new TaskRoomApplicationService(this.options.configPath).deleteTask({
1273
- actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1274
- }),
1273
+ deleteTask: taskId => this.deleteTaskFromOwner(sender, taskId, wireId),
1275
1274
  listRoomQueries: filter => new TaskRoomApplicationService(this.options.configPath).listRooms(filter),
1276
1275
  getRoomQuery: id => new TaskRoomApplicationService(this.options.configPath).getRoomDetail(id),
1277
1276
  listTemplateQueries: () => new TaskRoomApplicationService(this.options.configPath).listTemplates(),
@@ -1401,10 +1400,13 @@ export class OwnerChannel {
1401
1400
  const app = new TaskRoomApplicationService(this.options.configPath);
1402
1401
  const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1403
1402
  const begin = await app.beginTaskRecovery({ actor, taskId });
1404
- if (begin.kind === 'terminal_worker_required') {
1403
+ if (begin.kind !== 'final') {
1404
+ const deleting = begin.kind === 'deletion_worker_required';
1405
1405
  await this.send(sender.id, renderMarkdownFailure({
1406
1406
  kind: 'pending', subject: `/task recover ${taskId}`,
1407
- detail: 'The recovery request was accepted and is still being settled.',
1407
+ detail: deleting
1408
+ ? 'The task is pending deletion; recovery continues its cleanup.'
1409
+ : 'The recovery request was accepted and is still being settled.',
1408
1410
  action: `Run /task recover ${taskId} if it remains pending.`,
1409
1411
  }), wireId);
1410
1412
  this.state.remember(wireId);
@@ -1412,9 +1414,13 @@ export class OwnerChannel {
1412
1414
  await this.fleetOps.recoverTask(taskId);
1413
1415
  }
1414
1416
  catch (error) {
1415
- await app.recordSettlementError({ actor, taskId,
1416
- error: error instanceof Error ? error.message : String(error),
1417
- recoveryHint: `External settle worker failed to start. Retry /task recover ${taskId}.` });
1417
+ const failure = {
1418
+ actor, taskId, error: error instanceof Error ? error.message : String(error),
1419
+ recoveryHint: deleting
1420
+ ? `External delete worker failed to start. Retry /task delete ${taskId} ${taskId}.`
1421
+ : `External settle worker failed to start. Retry /task recover ${taskId}.`,
1422
+ };
1423
+ await (deleting ? app.recordDeletionError(failure) : app.recordSettlementError(failure));
1418
1424
  throw error;
1419
1425
  }
1420
1426
  return;
@@ -1439,6 +1445,36 @@ export class OwnerChannel {
1439
1445
  }), wireId);
1440
1446
  this.state.remember(wireId);
1441
1447
  }
1448
+ /** Accept a permanent any-state deletion, acknowledge it, then hand cleanup to a durable worker. */
1449
+ async deleteTaskFromOwner(sender, taskId, wireId) {
1450
+ const app = new TaskRoomApplicationService(this.options.configPath);
1451
+ const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1452
+ const accepted = await app.requestTaskDeletion({ actor, taskId });
1453
+ if (accepted.status === 'already_absent') {
1454
+ await this.send(sender.id, renderMarkdownResult({
1455
+ icon: '🗑️', title: 'Task already absent',
1456
+ fields: [{ label: 'ID', value: taskId, kind: 'code' }],
1457
+ }), wireId);
1458
+ this.state.remember(wireId);
1459
+ return;
1460
+ }
1461
+ await this.send(sender.id, renderMarkdownFailure({
1462
+ kind: 'pending', subject: `/task delete ${taskId} ${taskId}`,
1463
+ detail: 'The deletion was accepted; cleanup is settling in the background.',
1464
+ action: `Run /task recover ${taskId} or repeat /task delete ${taskId} ${taskId} if it remains pending.`,
1465
+ }), wireId);
1466
+ this.state.remember(wireId);
1467
+ try {
1468
+ await this.fleetOps.settleTaskDeletion(taskId);
1469
+ }
1470
+ catch (error) {
1471
+ await app.recordDeletionError({
1472
+ actor, taskId, error: error instanceof Error ? error.message : String(error),
1473
+ recoveryHint: `External delete worker failed to start. Retry /task delete ${taskId} ${taskId}.`,
1474
+ });
1475
+ throw error;
1476
+ }
1477
+ }
1442
1478
  async terminalTaskFromOwner(sender, taskId, kind, outcome, wireId) {
1443
1479
  const app = new TaskRoomApplicationService(this.options.configPath);
1444
1480
  const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };