@ours.network/fleet 1.1.0-nightly.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +231 -46
  2. package/dist/application/capabilities.js +18 -6
  3. package/dist/application/fleet-query-service.js +7 -4
  4. package/dist/application/role-command-service.js +9 -0
  5. package/dist/application/role-creation-service.d.ts +4 -4
  6. package/dist/application/role-creation-service.js +8 -1
  7. package/dist/application/role-repository.d.ts +4 -2
  8. package/dist/application/role-repository.js +6 -3
  9. package/dist/application/session-control.d.ts +3 -1
  10. package/dist/application/session-control.js +4 -2
  11. package/dist/application/task-room-service.d.ts +92 -39
  12. package/dist/application/task-room-service.js +562 -289
  13. package/dist/briefing.js +79 -22
  14. package/dist/build-info.json +5 -5
  15. package/dist/canonical-json.d.ts +2 -0
  16. package/dist/canonical-json.js +10 -0
  17. package/dist/cli.js +129 -47
  18. package/dist/config.d.ts +38 -3
  19. package/dist/config.js +193 -28
  20. package/dist/creation.d.ts +1 -1
  21. package/dist/creation.js +7 -4
  22. package/dist/docs.d.ts +1 -1
  23. package/dist/docs.js +181 -41
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +40 -4
  26. package/dist/fleet-command-audit.d.ts +22 -1
  27. package/dist/fleet-command-audit.js +355 -50
  28. package/dist/fleet-proxy.d.ts +5 -2
  29. package/dist/harness/claude-code-session.js +2 -1
  30. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  31. package/dist/harness/codex-app-server-proxy.js +348 -14
  32. package/dist/harness/codex-session.d.ts +9 -2
  33. package/dist/harness/codex-session.js +18 -2
  34. package/dist/harness/codex.d.ts +5 -1
  35. package/dist/harness/codex.js +104 -10
  36. package/dist/harness/types.d.ts +1 -1
  37. package/dist/index.d.ts +4 -3
  38. package/dist/index.js +3 -2
  39. package/dist/init-guidance.d.ts +1 -1
  40. package/dist/init-guidance.js +1 -1
  41. package/dist/init-wizard.d.ts +98 -0
  42. package/dist/init-wizard.js +631 -0
  43. package/dist/lifecycle-summary.d.ts +134 -0
  44. package/dist/lifecycle-summary.js +179 -0
  45. package/dist/loops/config.d.ts +10 -1
  46. package/dist/loops/config.js +38 -5
  47. package/dist/model-recovery.d.ts +1 -1
  48. package/dist/ops.js +2 -17
  49. package/dist/owner-channel/channel.d.ts +19 -3
  50. package/dist/owner-channel/channel.js +301 -120
  51. package/dist/owner-channel/commands.d.ts +28 -6
  52. package/dist/owner-channel/commands.js +109 -27
  53. package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
  54. package/dist/owner-channel/lifecycle-outbox.js +87 -0
  55. package/dist/owner-channel/ours-client.d.ts +19 -0
  56. package/dist/owner-channel/ours-client.js +3 -0
  57. package/dist/permissions.js +1 -1
  58. package/dist/preset-migration.d.ts +42 -0
  59. package/dist/preset-migration.js +450 -0
  60. package/dist/reports/artifact.d.ts +10 -0
  61. package/dist/reports/artifact.js +32 -0
  62. package/dist/reports/index.d.ts +5 -0
  63. package/dist/reports/index.js +5 -0
  64. package/dist/reports/render.d.ts +6 -0
  65. package/dist/reports/render.js +240 -0
  66. package/dist/reports/service.d.ts +42 -0
  67. package/dist/reports/service.js +57 -0
  68. package/dist/reports/tasks.d.ts +52 -0
  69. package/dist/reports/tasks.js +177 -0
  70. package/dist/reports/types.d.ts +315 -0
  71. package/dist/reports/types.js +6 -0
  72. package/dist/resolved-plan.js +13 -1
  73. package/dist/rooms-tasks/cli.d.ts +2 -0
  74. package/dist/rooms-tasks/cli.js +361 -285
  75. package/dist/rooms-tasks/close.d.ts +9 -1
  76. package/dist/rooms-tasks/close.js +23 -7
  77. package/dist/rooms-tasks/config.js +16 -25
  78. package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
  79. package/dist/rooms-tasks/cowork-adapter.js +13 -0
  80. package/dist/rooms-tasks/deletion.d.ts +51 -0
  81. package/dist/rooms-tasks/deletion.js +217 -0
  82. package/dist/rooms-tasks/external-worker.d.ts +8 -0
  83. package/dist/rooms-tasks/external-worker.js +38 -0
  84. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  85. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  86. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  87. package/dist/rooms-tasks/member-overrides.js +204 -0
  88. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  89. package/dist/rooms-tasks/member-startup.js +9 -2
  90. package/dist/rooms-tasks/provision.js +146 -83
  91. package/dist/rooms-tasks/room-state.d.ts +1 -0
  92. package/dist/rooms-tasks/room-state.js +12 -1
  93. package/dist/rooms-tasks/task-room-name.d.ts +12 -0
  94. package/dist/rooms-tasks/task-room-name.js +61 -0
  95. package/dist/rooms-tasks/task-state.d.ts +115 -3
  96. package/dist/rooms-tasks/task-state.js +361 -11
  97. package/dist/rooms-tasks/templates.d.ts +4 -1
  98. package/dist/rooms-tasks/templates.js +34 -3
  99. package/dist/rooms-tasks/terminal.d.ts +3 -0
  100. package/dist/rooms-tasks/terminal.js +10 -4
  101. package/dist/rooms-tasks/types.d.ts +92 -7
  102. package/dist/rooms-tasks/types.js +14 -1
  103. package/dist/runner.d.ts +3 -0
  104. package/dist/runner.js +91 -44
  105. package/dist/session/acp.d.ts +4 -1
  106. package/dist/session/acp.js +29 -13
  107. package/dist/session/arbiter.d.ts +2 -1
  108. package/dist/session/arbiter.js +2 -0
  109. package/dist/session/codex-app-server-transport.d.ts +53 -0
  110. package/dist/session/codex-app-server-transport.js +184 -0
  111. package/dist/session/codex-app-server.d.ts +122 -0
  112. package/dist/session/codex-app-server.js +1031 -0
  113. package/dist/session/control.d.ts +1 -1
  114. package/dist/session/control.js +17 -1
  115. package/dist/session/conversation-types.d.ts +4 -4
  116. package/dist/session/types.d.ts +24 -6
  117. package/dist/session/types.js +28 -0
  118. package/dist/spawn.d.ts +9 -0
  119. package/dist/spawn.js +77 -9
  120. package/dist/web/fleet-config-service.d.ts +1 -0
  121. package/dist/web/fleet-config-service.js +25 -7
  122. package/dist/web/runtime.js +11 -6
  123. package/dist/web/server.js +28 -1
  124. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  125. package/dist/web-app/index.html +1 -1
  126. package/package.json +4 -4
  127. package/presets/brain-catalog.json +17 -0
  128. package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
  129. package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
  130. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  131. package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
  132. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  133. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  134. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  135. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  136. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  137. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  138. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  139. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  140. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  141. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  142. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  143. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  144. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  145. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  146. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  147. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  148. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  149. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  150. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  151. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  152. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  153. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  154. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  155. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  156. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  157. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  158. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  159. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  186. package/presets/fleet/roles/Coordinator.yaml +37 -0
  187. package/presets/fleet/roles/Critic.yaml +23 -1
  188. package/presets/fleet/roles/Developer.yaml +27 -5
  189. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  190. package/presets/fleet/room_templates/pair.yaml +9 -7
  191. package/presets/fleet/room_templates/single.yaml +5 -4
  192. package/presets/fleet/room_templates/team.yaml +12 -8
  193. package/presets/manifest.json +2 -1
  194. package/presets/fleet/agents/Architect.yaml +0 -3
  195. package/presets/fleet/agents/Secretary.yaml +0 -3
  196. package/presets/fleet/agents/Tester.yaml +0 -3
  197. package/presets/fleet/roles/Agent.yaml +0 -6
  198. package/presets/fleet/roles/Architect.yaml +0 -6
  199. package/presets/fleet/roles/Secretary.yaml +0 -6
  200. package/presets/fleet/roles/Tester.yaml +0 -6
@@ -1,19 +1,26 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { join } from 'node:path';
3
4
  import { parse } from 'yaml';
4
5
  import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, activateRoom, getRoomRecord, } from './room-state.js';
5
6
  import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
7
+ import { storedRoomLaunchPolicy } from './types.js';
6
8
  import { spawnTemp } from '../spawn.js';
7
- import { findRole, loadConfig } from '../config.js';
9
+ import { effectivePermissionMode } from '../permissions.js';
10
+ import { selectionOrigin, summarizeResolvedLaunch, } from '../lifecycle-summary.js';
11
+ import { canonicalJson } from '../canonical-json.js';
12
+ import { readLaunchSnapshot, redactLaunchDefinition } from './launch-snapshot.js';
8
13
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from '../fleet-proxy.js';
9
14
  import { controlRequest } from '../session/control.js';
10
15
  import { SessionControlError } from '../session/types.js';
11
16
  import { closeManagedRoom } from './close.js';
17
+ import { withFileLock } from '../atomic-file.js';
18
+ import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
19
+ import { TaskStateError, taskDeletionState } from './task-state.js';
12
20
  import { buildRoomMemberTask, sha256Text } from './member-startup.js';
13
21
  import { agentDir } from '../paths.js';
14
22
  import { readProvenance } from '../creation.js';
15
23
  import { readTempSupervisor, secureStoppedTempArchive, tempArchiveForCreationAction, tempSupervisorLiveness, } from '../temp-lifecycle.js';
16
- import { recordFleetAuditPresentation } from '../fleet-command-audit.js';
17
24
  export function getBinPath() {
18
25
  try {
19
26
  return realpathSync(process.argv[1]);
@@ -22,42 +29,9 @@ export function getBinPath() {
22
29
  return process.argv[1];
23
30
  }
24
31
  }
25
- function canonicalJson(value) {
26
- if (Array.isArray(value))
27
- return `[${value.map(canonicalJson).join(',')}]`;
28
- if (value && typeof value === 'object')
29
- return `{${Object.entries(value)
30
- .sort(([a], [b]) => a.localeCompare(b))
31
- .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
32
- return JSON.stringify(value);
33
- }
34
- function selectionSummary(selection) {
35
- if ('ref' in selection)
36
- return `ref:${selection.ref} (reference)`;
37
- return `inline:sha256:${createHash('sha256').update(canonicalJson(selection.inline)).digest('hex').slice(0, 16)} (inline)`;
38
- }
39
- function permissionSummary(definition) {
40
- const permissions = definition.permissions;
41
- if (!permissions)
42
- return undefined;
43
- return `approval=${permissions.approval},filesystem=${permissions.filesystem},unattended=${permissions.unattended}`;
44
- }
45
32
  function launchDefinition(definition) {
46
- const selectionEvidence = (selection) => 'ref' in selection ? { kind: 'ref', id: selection.ref } : {
47
- kind: 'inline', fingerprint: `sha256:${createHash('sha256')
48
- .update(canonicalJson(selection.inline)).digest('hex').slice(0, 16)}`,
49
- };
50
33
  return {
51
- projection: {
52
- brain: selectionEvidence(definition.brain), role: selectionEvidence(definition.role),
53
- ...(definition.permissions ? { permissions: {
54
- approval: definition.permissions.approval,
55
- filesystem: definition.permissions.filesystem,
56
- unattended: definition.permissions.unattended,
57
- } } : {}),
58
- operationalFields: Object.keys(definition)
59
- .filter(key => key !== 'brain' && key !== 'role').sort(),
60
- },
34
+ projection: redactLaunchDefinition(definition),
61
35
  fingerprint: createHash('sha256').update(canonicalJson(definition)).digest('hex'),
62
36
  };
63
37
  }
@@ -81,8 +55,27 @@ async function spawnRoomMember(options, binPath) {
81
55
  statePath: result.statePath,
82
56
  creationActionId: result.creationActionId,
83
57
  callerRole: result.caller,
58
+ ...(result.configuration ? { configuration: result.configuration } : {}),
84
59
  };
85
60
  }
61
+ /**
62
+ * Launch-boundary capture: the exact ResolvedRole the spawn persisted,
63
+ * whitelisted into the operator-facing presentation. Capture is required for
64
+ * every launched room member — launchMatches has already proved role.yaml
65
+ * readable, and effectivePermissionMode is total over registered harnesses —
66
+ * so a failure here is a capture defect and must surface, not degrade to the
67
+ * legacy rendering.
68
+ */
69
+ function presentationFromStatePath(statePath, settings, coworkRole) {
70
+ const resolved = parse(readFileSync(join(statePath, 'role.yaml'), 'utf8'));
71
+ return summarizeResolvedLaunch(resolved, {
72
+ role: selectionOrigin(settings.definition.role),
73
+ brain: selectionOrigin(settings.definition.brain),
74
+ template: settings.template,
75
+ permissionMode: effectivePermissionMode(resolved),
76
+ missionFallback: coworkRole,
77
+ });
78
+ }
86
79
  function shortId(id) { return id.slice(0, 8); }
87
80
  function expandMembers(template, prefix) {
88
81
  const result = [];
@@ -92,39 +85,41 @@ function expandMembers(template, prefix) {
92
85
  name: `${prefix}-${slot.slot}-${i}`,
93
86
  slot: slot.slot,
94
87
  coworkRole: slot.role,
95
- agent: structuredClone(slot.agent),
88
+ agentTemplate: slot.agent_template,
89
+ launchDefinitionId: slot.launch_definition_id ?? slot.agent_template,
90
+ agentTemplateHash: slot.agent_template_hash,
91
+ loopSource: slot.loop_source ?? 'omitted',
96
92
  });
97
93
  }
98
94
  }
99
95
  return result;
100
96
  }
101
- function settingsFor(member, cfg) {
102
- if (!('ref' in member.agent)) {
103
- const role = member.agent.role;
104
- if (cfg.files[0] && existsSync(cfg.files[0]) && statSync(cfg.files[0]).isFile())
105
- loadConfig(cfg.files[0], { additionalAgent: {
106
- id: `RoomPreflight${member.name.replace(/[^A-Za-z0-9_-]/g, '')}`.slice(0, 64),
107
- definition: member.agent,
108
- } });
109
- return { definition: structuredClone(member.agent),
110
- ...('inline' in role && typeof role.inline.persona === 'string'
111
- ? { persona: role.inline.persona } : {}) };
112
- }
113
- const refRole = findRole(cfg, member.agent.ref);
114
- const definition = cfg.agentDefinitions?.[member.agent.ref];
97
+ function settingsFor(member, cfg, sealed) {
98
+ const definition = sealed
99
+ ? sealed[member.launchDefinitionId]
100
+ : cfg.agentTemplates?.[member.agentTemplate];
115
101
  if (!definition)
116
- throw new Error(`Agent '${member.agent.ref}' has no canonical definition`);
102
+ throw new Error(sealed
103
+ ? `Sealed Agent definition '${member.launchDefinitionId}' not found`
104
+ : `Agent Template '${member.agentTemplate}' not found`);
105
+ const role = definition.role;
117
106
  return {
118
107
  definition: structuredClone(definition),
119
- persona: refRole.persona,
108
+ ...('inline' in role && typeof role.inline.persona === 'string'
109
+ ? { persona: role.inline.persona } : {}),
110
+ template: member.agentTemplate,
111
+ templateHash: member.agentTemplateHash
112
+ ?? createHash('sha256').update(canonicalJson(definition)).digest('hex'),
113
+ loopSource: member.loopSource,
120
114
  };
121
115
  }
122
- function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid) {
116
+ function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCid, anonymous) {
123
117
  return buildRoomMemberTask({
124
118
  taskId: input.taskId,
125
119
  roomId: input.roomId,
126
120
  roomIdentityCid,
127
121
  ownerSeatCid,
122
+ anonymous,
128
123
  goal: input.goal,
129
124
  brief: input.brief,
130
125
  contract: input.template.contract,
@@ -139,7 +134,7 @@ function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCi
139
134
  })),
140
135
  });
141
136
  }
142
- function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId) {
137
+ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId, anonymous = false) {
143
138
  const provenance = readProvenance(dir);
144
139
  if (provenance?.creationActionId !== actionId || provenance.role !== member.name)
145
140
  return false;
@@ -151,6 +146,7 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
151
146
  && startup.room_identity_cid === roomIdentityCid
152
147
  && startup.identity_name === member.name
153
148
  && startup.role === member.coworkRole
149
+ && (startup.anonymous ?? false) === anonymous
154
150
  && sha256Text(startup.task ?? '') === taskSha
155
151
  && (expectedInviteId === undefined || startup.invite_id === expectedInviteId)
156
152
  && typeof startup.invite === 'string'
@@ -161,21 +157,22 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
161
157
  }
162
158
  }
163
159
  async function retainRunningLaunch(input) {
164
- const { provision, member, task, roomIdentityCid } = input;
160
+ const { provision, member, settings, task, roomIdentityCid } = input;
165
161
  let seat = getRoomRecord(provision.roomId).member_seats
166
162
  .find(candidate => candidate.role_name === member.name);
167
163
  const dir = agentDir(member.name, true);
168
164
  const taskSha = sha256Text(task);
165
+ const anonymous = storedRoomLaunchPolicy(getRoomRecord(provision.roomId)?.room_policy).anonymous;
169
166
  if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched'
170
167
  || seat.launch?.state === 'failed') && existsSync(dir)) {
171
- if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
168
+ if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
172
169
  const provenance = readProvenance(dir);
173
170
  const adoptable = (seat.launch.state === 'intent' || seat.launch.state === 'failed')
174
171
  && Boolean(seat.launch.caller_role)
175
172
  && provenance?.surface === 'agent'
176
173
  && provenance.callerRole === seat.launch.caller_role
177
174
  && typeof provenance.creationActionId === 'string'
178
- && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id);
175
+ && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous);
179
176
  if (!adoptable)
180
177
  throw new Error(`existing launch for ${member.name} does not match its durable intent`);
181
178
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -193,9 +190,14 @@ async function retainRunningLaunch(input) {
193
190
  throw new Error(`existing launch for ${member.name} has unknown liveness`);
194
191
  const retainedLaunch = seat.launch;
195
192
  if (live === 'running') {
193
+ // A crash between spawn and the seat update (or a pre-upgrade launch)
194
+ // can retain a running member without a captured presentation; backfill
195
+ // it from the same persisted resolved role the match was proved against.
196
+ const presentation = retainedLaunch.presentation
197
+ ?? presentationFromStatePath(dir, settings, member.coworkRole);
196
198
  updateMemberStartup(provision.roomId, member.name, { launch: {
197
199
  ...retainedLaunch, state: 'launched', launch_id: supervisor.launchId,
198
- updated_at: new Date().toISOString(),
200
+ presentation, updated_at: new Date().toISOString(),
199
201
  } });
200
202
  return true;
201
203
  }
@@ -210,7 +212,7 @@ async function retainRunningLaunch(input) {
210
212
  if (!seat.launch.launch_id || !seat.launch.action_id)
211
213
  throw new Error(`missing durable launch identity for disappeared ${member.name}`);
212
214
  const archive = await secureStoppedTempArchive(member.name, seat.launch.launch_id);
213
- if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
215
+ if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
214
216
  throw new Error(`archive for disappeared ${member.name} does not match its durable intent`);
215
217
  }
216
218
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -222,7 +224,7 @@ async function retainRunningLaunch(input) {
222
224
  if (!seat.launch.action_id)
223
225
  throw new Error(`missing action ID for disappeared launch intent ${member.name}`);
224
226
  const archive = tempArchiveForCreationAction(member.name, seat.launch.action_id);
225
- if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
227
+ if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
226
228
  throw new Error(`launch intent for ${member.name} has no exact live or terminated archive evidence`);
227
229
  }
228
230
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -232,7 +234,25 @@ async function retainRunningLaunch(input) {
232
234
  }
233
235
  return false;
234
236
  }
237
+ /**
238
+ * Member-launch publication window: for a task-bound room, the durable launch
239
+ * intent, spawn, and launch record publish under the common task-operation
240
+ * lock so an accepted deletion linearizes strictly before (bounded abort — no
241
+ * spawn happens) or strictly after (the seat's launch record is visible to
242
+ * deletion retirement). The lock is held per member only, never across
243
+ * seat-wait loops.
244
+ */
235
245
  async function launchMember(input) {
246
+ const taskId = input.provision.taskId;
247
+ if (!taskId)
248
+ return launchMemberUnlocked(input);
249
+ return withFileLock(taskOperationLockPath(taskId), () => {
250
+ if (taskDeletionState(taskId) !== 'none')
251
+ throw new Error(`task ${taskId} is pending deletion; aborting member launch for ${input.member.name}`);
252
+ return launchMemberUnlocked(input);
253
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
254
+ }
255
+ async function launchMemberUnlocked(input) {
236
256
  const { provision, member, settings, startup } = input;
237
257
  const seat = getRoomRecord(provision.roomId).member_seats
238
258
  .find(candidate => candidate.role_name === member.name);
@@ -247,6 +267,7 @@ async function launchMember(input) {
247
267
  updateMemberStartup(provision.roomId, member.name, { launch: {
248
268
  state: 'intent', attempt, action_id: actionId, mission_sha256: taskSha,
249
269
  agent_definition: agentDefinition, agent_fingerprint: agentFingerprint,
270
+ agent_template: settings.template, agent_template_hash: settings.templateHash,
250
271
  ...(proxyCaller ? { caller_role: proxyCaller } : {}),
251
272
  updated_at: new Date().toISOString(),
252
273
  } });
@@ -256,6 +277,9 @@ async function launchMember(input) {
256
277
  temp: true,
257
278
  identity: member.name,
258
279
  agentDefinition: settings.definition,
280
+ ...(settings.loopSource === 'cli' && settings.definition.loops === undefined
281
+ ? { noLoops: true } : {}),
282
+ loopSource: settings.loopSource,
259
283
  surface: 'agent',
260
284
  creationActionId: actionId,
261
285
  roomMemberStartup: startup,
@@ -266,36 +290,38 @@ async function launchMember(input) {
266
290
  updateMemberStartup(provision.roomId, member.name, { launch: {
267
291
  state: 'intent', attempt, action_id: launched.creationActionId,
268
292
  mission_sha256: taskSha, agent_definition: agentDefinition,
269
- agent_fingerprint: agentFingerprint, updated_at: new Date().toISOString(),
293
+ agent_fingerprint: agentFingerprint,
294
+ agent_template: settings.template, agent_template_hash: settings.templateHash,
295
+ updated_at: new Date().toISOString(),
270
296
  ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
271
297
  } });
272
298
  }
273
299
  const supervisor = readTempSupervisor(launchedDir);
274
- if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
300
+ if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id, startup.anonymous ?? false)) {
275
301
  throw new Error(`new launch for ${member.name} did not persist matching provenance`);
276
302
  }
303
+ const presentation = launched.configuration
304
+ ? { ...launched.configuration, template: settings.template,
305
+ ...(launched.configuration.mission ? {} : { mission: member.coworkRole }) }
306
+ : presentationFromStatePath(launchedDir, settings, member.coworkRole);
277
307
  updateMemberStartup(provision.roomId, member.name, { launch: {
278
308
  state: 'launched', attempt, action_id: launched.creationActionId, mission_sha256: taskSha,
279
309
  agent_definition: agentDefinition, agent_fingerprint: agentFingerprint,
310
+ agent_template: settings.template, agent_template_hash: settings.templateHash,
311
+ presentation,
280
312
  ...(launched.callerRole ? { caller_role: launched.callerRole } : {}),
281
313
  launch_id: supervisor.launchId, updated_at: new Date().toISOString(),
282
314
  } });
283
- recordFleetAuditPresentation({ kind: 'agent_started', id: member.name, name: member.name,
284
- lifetime: 'temporary', brain: selectionSummary(settings.definition.brain),
285
- role: selectionSummary(settings.definition.role), harness: 'resolved', session: 'acp',
286
- permissions: permissionSummary(settings.definition), parent: provision.roomId,
287
- actionId: launched.creationActionId, inherited: [] });
288
315
  }
289
316
  catch (error) {
290
317
  updateMemberStartup(provision.roomId, member.name, { launch: {
291
318
  state: 'failed', attempt, action_id: effectiveActionId, mission_sha256: taskSha,
292
319
  agent_definition: agentDefinition, agent_fingerprint: agentFingerprint,
320
+ agent_template: settings.template, agent_template_hash: settings.templateHash,
293
321
  ...(proxyCaller ? { caller_role: proxyCaller } : {}),
294
322
  updated_at: new Date().toISOString(),
295
323
  error: error instanceof Error ? error.message : String(error),
296
324
  } });
297
- recordFleetAuditPresentation({ kind: 'lifecycle_failure', resource: 'Agent', id: member.name,
298
- state: 'failed', category: 'readiness_failed', eventId: effectiveActionId });
299
325
  throw error;
300
326
  }
301
327
  }
@@ -313,9 +339,10 @@ function exactCoworkSeat(observed, member, expectedInviteId) {
313
339
  }
314
340
  return seat;
315
341
  }
316
- function reconcileMemberSeats(roomId, members, observed) {
342
+ function reconcileMemberSeats(roomId, members, observed, ownerSeatCid) {
317
343
  const current = getRoomRecord(roomId);
318
- let complete = true;
344
+ let complete = !ownerSeatCid || observed.some(seat => seat.identity_cid.toLowerCase() === ownerSeatCid.toLowerCase()
345
+ && seat.seat_state === 'active');
319
346
  const seats = current.member_seats.map(seat => {
320
347
  const member = members.find(candidate => candidate.name === seat.role_name);
321
348
  const found = exactCoworkSeat(observed, member, seat.invite_id);
@@ -333,24 +360,35 @@ function reconcileMemberSeats(roomId, members, observed) {
333
360
  updateMemberSeats(roomId, seats);
334
361
  return { complete, seats };
335
362
  }
363
+ function assertCoworkRoomPolicy(room, expectedAnonymous) {
364
+ if ((room.anonymous ?? false) !== expectedAnonymous)
365
+ throw new Error(`Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(expectedAnonymous)})`);
366
+ }
336
367
  export async function provisionMembers(input) {
337
368
  const { cfg, cowork, roomId, taskId, template } = input;
369
+ // Deletion-epoch pre-check; each member launch re-checks under the lock.
370
+ if (taskId && taskDeletionState(taskId) !== 'none')
371
+ throw new Error(`task ${taskId} is pending deletion; refusing to provision members`);
338
372
  const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
339
373
  const members = expandMembers(template, prefix);
340
374
  // Resolve every Agent before persisting launch intent or touching Cowork membership.
341
- const settings = new Map(members.map(member => [member.name, settingsFor(member, cfg)]));
375
+ const sealed = template.launch_snapshot_hash
376
+ ? readLaunchSnapshot(template.launch_snapshot_hash) : undefined;
377
+ const settings = new Map(members.map(member => [member.name, settingsFor(member, cfg, sealed)]));
342
378
  const existing = getRoomRecord(roomId);
343
379
  if (!existing?.room_identity_cid)
344
380
  throw new Error(`room ${roomId} has no pinned room identity CID`);
345
381
  const roomIdentityCid = existing.room_identity_cid;
346
382
  const ownerSeatCid = existing.owner_seat_cid ?? null;
383
+ const roomPolicy = storedRoomLaunchPolicy(existing.room_policy);
347
384
  const persistedNames = new Set(existing.member_seats.map(seat => seat.role_name));
348
385
  const resuming = members.length > 0
349
386
  && members.every(member => persistedNames.has(member.name));
350
387
  if (!resuming) {
351
388
  advanceSaga(roomId, 'create_members', 3);
352
389
  updateMemberSeats(roomId, members.map(member => {
353
- const evidence = launchDefinition(settings.get(member.name).definition);
390
+ const memberSettings = settings.get(member.name);
391
+ const evidence = launchDefinition(memberSettings.definition);
354
392
  return ({
355
393
  role_name: member.name,
356
394
  slot: member.slot,
@@ -358,6 +396,8 @@ export async function provisionMembers(input) {
358
396
  seat_state: 'pending',
359
397
  launch: { state: 'pending', attempt: 0,
360
398
  agent_definition: evidence.projection, agent_fingerprint: evidence.fingerprint,
399
+ agent_template: memberSettings.template,
400
+ agent_template_hash: memberSettings.templateHash,
361
401
  updated_at: new Date().toISOString() },
362
402
  });
363
403
  }));
@@ -370,7 +410,7 @@ export async function provisionMembers(input) {
370
410
  throw new Error(`Agent definition drift for ${member.name}; durable launch intent does not match current configuration`);
371
411
  }
372
412
  }
373
- const tasks = new Map(members.map(member => [member.name, roomTask(input, member, settings.get(member.name), members, roomIdentityCid, ownerSeatCid)]));
413
+ const tasks = new Map(members.map(member => [member.name, roomTask(input, member, settings.get(member.name), members, roomIdentityCid, ownerSeatCid, roomPolicy.anonymous)]));
374
414
  const policy = {
375
415
  timeoutMs: input.startupWait?.timeoutMs ?? 60_000,
376
416
  initialDelayMs: input.startupWait?.initialDelayMs ?? 250,
@@ -381,20 +421,23 @@ export async function provisionMembers(input) {
381
421
  advanceSaga(roomId, 'join_role_groups', 4);
382
422
  try {
383
423
  const initialRoom = await cowork.recoverRoom(roomId);
384
- reconcileMemberSeats(roomId, members, initialRoom.seats);
424
+ assertCoworkRoomPolicy(initialRoom, roomPolicy.anonymous);
425
+ reconcileMemberSeats(roomId, members, initialRoom.seats, existing.owner_seat_cid);
385
426
  for (const member of members) {
386
427
  const task = tasks.get(member.name);
387
428
  const currentSeat = getRoomRecord(roomId).member_seats
388
429
  .find(seat => seat.role_name === member.name);
389
430
  if (currentSeat.seat_state === 'active') {
390
431
  if (!await retainRunningLaunch({
391
- provision: input, member, task, roomIdentityCid,
432
+ provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
392
433
  })) {
393
434
  throw new Error(`active Cowork seat ${member.name} has no matching live Fleet launch`);
394
435
  }
395
436
  continue;
396
437
  }
397
- if (await retainRunningLaunch({ provision: input, member, task, roomIdentityCid }))
438
+ if (await retainRunningLaunch({
439
+ provision: input, member, settings: settings.get(member.name), task, roomIdentityCid,
440
+ }))
398
441
  continue;
399
442
  const issued = await cowork.issueInvite(roomId, {
400
443
  mode: 'one_time', role: member.coworkRole, min_accepts: 1,
@@ -415,6 +458,7 @@ export async function provisionMembers(input) {
415
458
  role: member.coworkRole,
416
459
  task,
417
460
  owner_seat_cid: ownerSeatCid,
461
+ anonymous: roomPolicy.anonymous,
418
462
  },
419
463
  });
420
464
  }
@@ -426,9 +470,18 @@ export async function provisionMembers(input) {
426
470
  }
427
471
  catch (error) {
428
472
  const reason = error instanceof Error ? error.message : String(error);
429
- setSagaError(roomId, reason, 'Member invite or launch failed. Retry with `task recover`.', 'member_failed');
430
- if (taskId)
431
- blockTask(taskId, reason);
473
+ setSagaError(roomId, reason, 'Member invite or launch failed. Inspect role logs, then retry with `task start`.', 'member_failed');
474
+ if (taskId) {
475
+ // A deletion-pending (or already-terminal) task rejects the block
476
+ // overlay; the original launch failure must still propagate.
477
+ try {
478
+ blockTask(taskId, reason);
479
+ }
480
+ catch (blockError) {
481
+ if (!(blockError instanceof TaskStateError))
482
+ throw blockError;
483
+ }
484
+ }
432
485
  throw error;
433
486
  }
434
487
  advanceSaga(roomId, 'wait_seats', 5);
@@ -436,7 +489,8 @@ export async function provisionMembers(input) {
436
489
  let delay = policy.initialDelayMs;
437
490
  for (;;) {
438
491
  const remote = await cowork.recoverRoom(roomId);
439
- const reconciled = reconcileMemberSeats(roomId, members, remote.seats);
492
+ assertCoworkRoomPolicy(remote, roomPolicy.anonymous);
493
+ const reconciled = reconcileMemberSeats(roomId, members, remote.seats, ownerSeatCid ?? undefined);
440
494
  if (reconciled.complete)
441
495
  break;
442
496
  if (policy.now() >= deadline) {
@@ -447,6 +501,15 @@ export async function provisionMembers(input) {
447
501
  await policy.sleep(delay);
448
502
  delay = Math.min(policy.maxDelayMs, Math.max(delay + 1, delay * 2));
449
503
  }
504
+ // Admission alone is insufficient: every expected member must still have
505
+ // the exact live launch that owns its authenticated Cowork seat.
506
+ for (const member of members) {
507
+ if (!await retainRunningLaunch({
508
+ provision: input, member, settings: settings.get(member.name),
509
+ task: tasks.get(member.name), roomIdentityCid,
510
+ }))
511
+ throw new Error(`active Cowork seat ${member.name} has no matching live Fleet launch`);
512
+ }
450
513
  if (taskId) {
451
514
  const taskMembers = getRoomRecord(roomId).member_seats.map(seat => {
452
515
  if (!seat.identity_cid)
@@ -9,6 +9,7 @@ export interface CreateRoomInput {
9
9
  room_identity_cid?: string;
10
10
  task_id?: string;
11
11
  template_snapshot?: import('./types.js').TemplateSnapshot;
12
+ room_policy?: import('./types.js').RoomLaunchPolicy;
12
13
  }
13
14
  export declare function createRoomRecord(input: CreateRoomInput): RoomOrchestrationRecord;
14
15
  export declare function getRoomRecord(id: string): RoomOrchestrationRecord | undefined;
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:f
2
2
  import { join } from 'node:path';
3
3
  import { replaceFileAtomically } from '../atomic-file.js';
4
4
  import { stateRoot } from '../paths.js';
5
+ import { storedRoomLaunchPolicy } from './types.js';
6
+ import { releaseLaunchSnapshot } from './launch-snapshot.js';
5
7
  export const roomsDir = () => join(stateRoot(), 'rooms');
6
8
  function roomPath(id) { return join(roomsDir(), `${id}.json`); }
7
9
  export class RoomStateError extends Error {
@@ -18,8 +20,13 @@ function writeRoom(record) {
18
20
  }
19
21
  export function createRoomRecord(input) {
20
22
  const existing = getRoomRecord(input.room_id);
21
- if (existing)
23
+ if (existing) {
24
+ const before = storedRoomLaunchPolicy(existing.room_policy);
25
+ const requested = storedRoomLaunchPolicy(input.room_policy);
26
+ if (JSON.stringify(before) !== JSON.stringify(requested))
27
+ throw new RoomStateError(`room ${input.room_id} launch policy mismatch`);
22
28
  return existing;
29
+ }
23
30
  const record = {
24
31
  room_id: input.room_id,
25
32
  room_name: input.room_name,
@@ -27,6 +34,7 @@ export function createRoomRecord(input) {
27
34
  room_identity_cid: input.room_identity_cid,
28
35
  task_id: input.task_id,
29
36
  template_snapshot: input.template_snapshot,
37
+ room_policy: input.room_policy,
30
38
  saga: { phase: 'persist_intent', step_index: 0 },
31
39
  member_seats: [],
32
40
  state: 'provisioning',
@@ -46,8 +54,11 @@ export function getRoomRecord(id) {
46
54
  /** Remove a terminal orchestration record from the live room inventory. */
47
55
  export function deleteRoomRecord(id) {
48
56
  const path = roomPath(id);
57
+ const hash = getRoomRecord(id)?.template_snapshot?.launch_snapshot_hash;
49
58
  if (existsSync(path))
50
59
  rmSync(path);
60
+ if (hash)
61
+ releaseLaunchSnapshot(hash);
51
62
  }
52
63
  export function listRoomRecords(filter) {
53
64
  const dir = roomsDir();
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Cowork room identities are named `ours-cowork:<room name>` and both the
3
+ * room and identity contracts count NFC-normalized Unicode code points.
4
+ * Keeping the complete task ID inside that shared budget makes every derived
5
+ * name stable, directly correlated, and unique even when the title is empty,
6
+ * sanitized, or truncated.
7
+ */
8
+ export declare const COWORK_ROOM_IDENTITY_PREFIX = "ours-cowork:";
9
+ export declare const COWORK_IDENTITY_NAME_MAX_CODE_POINTS = 64;
10
+ export declare const TASK_ROOM_NAME_MAX_CODE_POINTS: number;
11
+ /** Canonical Cowork room name for a Fleet task; never mutates the task title. */
12
+ export declare function deriveTaskRoomName(title: string, taskId: string): string;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cowork room identities are named `ours-cowork:<room name>` and both the
3
+ * room and identity contracts count NFC-normalized Unicode code points.
4
+ * Keeping the complete task ID inside that shared budget makes every derived
5
+ * name stable, directly correlated, and unique even when the title is empty,
6
+ * sanitized, or truncated.
7
+ */
8
+ export const COWORK_ROOM_IDENTITY_PREFIX = 'ours-cowork:';
9
+ export const COWORK_IDENTITY_NAME_MAX_CODE_POINTS = 64;
10
+ export const TASK_ROOM_NAME_MAX_CODE_POINTS = COWORK_IDENTITY_NAME_MAX_CODE_POINTS
11
+ - Array.from(COWORK_ROOM_IDENTITY_PREFIX).length;
12
+ const TASK_ID_PATTERN = /^[0-9a-z]{9}[0-9a-f]{8}$/;
13
+ const FORBIDDEN = /[\\/\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u;
14
+ const DASH_SEPARATOR = /\p{Pd}/u;
15
+ const WHITESPACE = /\s/u;
16
+ const FALLBACK_TITLE = 'Task';
17
+ function sanitizeTitle(title) {
18
+ const normalized = title.normalize('NFC');
19
+ let result = '';
20
+ let pending;
21
+ for (const character of normalized) {
22
+ if (FORBIDDEN.test(character) || DASH_SEPARATOR.test(character)) {
23
+ pending = 'separator';
24
+ }
25
+ else if (WHITESPACE.test(character)) {
26
+ if (pending !== 'separator')
27
+ pending = 'space';
28
+ }
29
+ else {
30
+ if (result && pending)
31
+ result += pending === 'separator' ? ' - ' : ' ';
32
+ result += character;
33
+ pending = undefined;
34
+ }
35
+ }
36
+ return result;
37
+ }
38
+ /** Truncate to a code-point budget without cutting an extended grapheme. */
39
+ function graphemeBound(value, maxCodePoints) {
40
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
41
+ let result = '';
42
+ let length = 0;
43
+ for (const { segment } of segmenter.segment(value)) {
44
+ const segmentLength = Array.from(segment).length;
45
+ if (length + segmentLength > maxCodePoints)
46
+ break;
47
+ result += segment;
48
+ length += segmentLength;
49
+ }
50
+ return result.trimEnd();
51
+ }
52
+ /** Canonical Cowork room name for a Fleet task; never mutates the task title. */
53
+ export function deriveTaskRoomName(title, taskId) {
54
+ if (!TASK_ID_PATTERN.test(taskId))
55
+ throw new Error(`invalid canonical task ID: ${taskId}`);
56
+ const suffix = ` [${taskId}]`;
57
+ const titleBudget = TASK_ROOM_NAME_MAX_CODE_POINTS - Array.from(suffix).length;
58
+ const readable = sanitizeTitle(title);
59
+ const bounded = graphemeBound(readable || FALLBACK_TITLE, titleBudget) || FALLBACK_TITLE;
60
+ return `${bounded}${suffix}`;
61
+ }