@ours.network/fleet 2.2.5 → 2.3.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 (38) hide show
  1. package/dist/application/task-room-service.d.ts +11 -0
  2. package/dist/application/task-room-service.js +37 -14
  3. package/dist/atomic-file.d.ts +2 -0
  4. package/dist/atomic-file.js +2 -1
  5. package/dist/build-info.json +4 -4
  6. package/dist/config.d.ts +6 -0
  7. package/dist/config.js +6 -3
  8. package/dist/docs.d.ts +1 -1
  9. package/dist/docs.js +37 -0
  10. package/dist/owner-channel/commands.js +25 -12
  11. package/dist/reports/tasks.js +4 -3
  12. package/dist/rooms-tasks/add-member.js +17 -6
  13. package/dist/rooms-tasks/cli.js +79 -5
  14. package/dist/rooms-tasks/config.js +11 -0
  15. package/dist/rooms-tasks/layout-member-retirement.d.ts +15 -0
  16. package/dist/rooms-tasks/layout-member-retirement.js +26 -1
  17. package/dist/rooms-tasks/layout-service.d.ts +2 -1
  18. package/dist/rooms-tasks/layout-service.js +10 -4
  19. package/dist/rooms-tasks/layout.d.ts +4 -2
  20. package/dist/rooms-tasks/layout.js +21 -4
  21. package/dist/rooms-tasks/member-naming.d.ts +43 -0
  22. package/dist/rooms-tasks/member-naming.js +186 -0
  23. package/dist/rooms-tasks/member-overrides.js +1 -0
  24. package/dist/rooms-tasks/mission-keys.d.ts +83 -0
  25. package/dist/rooms-tasks/mission-keys.js +250 -0
  26. package/dist/rooms-tasks/provision.d.ts +4 -0
  27. package/dist/rooms-tasks/provision.js +37 -3
  28. package/dist/rooms-tasks/task-lists.d.ts +18 -2
  29. package/dist/rooms-tasks/task-lists.js +60 -5
  30. package/dist/rooms-tasks/task-room-name.d.ts +7 -2
  31. package/dist/rooms-tasks/task-room-name.js +13 -4
  32. package/dist/rooms-tasks/task-state.d.ts +40 -0
  33. package/dist/rooms-tasks/task-state.js +288 -45
  34. package/dist/rooms-tasks/types.d.ts +35 -1
  35. package/dist/rooms-tasks/types.js +1 -1
  36. package/dist/web/runtime.js +4 -1
  37. package/dist/web/server.js +62 -27
  38. package/package.json +5 -5
@@ -5,13 +5,13 @@ import { join } from 'node:path';
5
5
  import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
6
6
  import { canonicalJson } from '../canonical-json.js';
7
7
  import { stateRoot } from '../paths.js';
8
- import { getTask, TaskStateError } from './task-state.js';
8
+ import { allocateTaskMemberNames, getTask, TaskStateError } from './task-state.js';
9
9
  import { getRoomRecord } from './room-state.js';
10
10
  import { prepareExecutionPlan } from './member-overrides.js';
11
11
  import { sealTemplateSnapshot } from './templates.js';
12
- import { provisionMembers } from './provision.js';
12
+ import { assertMemberNameAvailable, provisionMembers } from './provision.js';
13
13
  import { EMPTY_TASK_LAYOUT, EMPTY_TASK_ROOM } from './empty-layout.js';
14
- import { layoutOwnedMemberName } from './layout-member-retirement.js';
14
+ import { allocateLayoutMemberName } from './layout-member-retirement.js';
15
15
  import { validLayoutKey } from './layout-config.js';
16
16
  export class MemberAdditionRejected extends TaskStateError {
17
17
  accepted = false;
@@ -71,7 +71,11 @@ export async function requestMemberAddition(input) {
71
71
  }
72
72
  })();
73
73
  const receipt = { taskId, requestId: r.requestId,
74
- roleId: layoutOwnedMemberName(task.layout.run_id, r.slot), hash, state: 'running' };
74
+ roleId: allocateLayoutMemberName(task.layout.run_id, r.slot, [
75
+ { label: r.role, source: 'role_override' },
76
+ { label: input.cfg.agentTemplateRoleRefs?.[r.agentTemplate], source: 'template_ref' },
77
+ { label: r.slot, source: 'participant_key' }
78
+ ]), hash, state: 'running' };
75
79
  const save = () => { replaceFileAtomically(path, JSON.stringify(receipt) + '\n', 0o600); publishFleetResourceCommit(path); };
76
80
  save();
77
81
  running.add(path);
@@ -89,7 +93,14 @@ export async function requestMemberAddition(input) {
89
93
  if (!room || !['active', 'review'].includes(task.state) || task.terminal_intent || room.state !== 'active'
90
94
  || room.task_id !== taskId || room.room_identity_cid !== task.room_identity_cid)
91
95
  throw new MemberAdditionRejected('Adding an agent requires an active task room');
92
- const roleId = `${taskId.slice(0, 8)}-${r.slot}-1`;
96
+ // Keyed tasks take the next durable role ordinal; unkeyed tasks keep the legacy one-per-slot name.
97
+ const allocationKey = `add:${r.requestId}`;
98
+ const roleId = allocateTaskMemberNames(taskId, [{ allocationKey, candidates: [
99
+ { label: r.role, source: 'role_override' },
100
+ { label: input.cfg.agentTemplateRoleRefs?.[r.agentTemplate], source: 'template_ref' },
101
+ { label: r.role, source: 'room_role' }, { label: r.slot, source: 'slot' }
102
+ ] }], assertMemberNameAvailable)?.[0]
103
+ ?? `${taskId.slice(0, 8)}-${r.slot}-1`;
93
104
  if (room.member_seats.some(s => s.role_name === roleId))
94
105
  throw new MemberAdditionRejected('This member slot already exists');
95
106
  const plan = (() => {
@@ -107,7 +118,7 @@ export async function requestMemberAddition(input) {
107
118
  save();
108
119
  running.add(path);
109
120
  void (input.provision ?? provisionMembers)({ cfg: input.cfg, cowork: input.cowork, configPath: input.configPath, binPath: input.binPath,
110
- taskId, roomId: room.room_id, template, goal: task.title, brief: task.brief, append: true }).then(result => {
121
+ taskId, roomId: room.room_id, template, goal: task.title, brief: task.brief, append: true, allocationKey }).then(result => {
111
122
  const seat = result.member_seats.find(s => s.role_name === roleId);
112
123
  receipt.state = seat?.seat_state === 'active' && getTask(taskId).member_roles.some(m => m.name === roleId) ? 'succeeded' : 'attention';
113
124
  if (receipt.state === 'attention')
@@ -39,7 +39,7 @@ function cliAnonymousOverride(argv) {
39
39
  throw new Error('--anonymous and --no-anonymous are mutually exclusive');
40
40
  return enabled ? true : disabled ? false : undefined;
41
41
  }
42
- import { getTask, getDeletingTask, TaskStateError, reviewRevision, } from './task-state.js';
42
+ import { getTask, getDeletingTask, resolveTaskRef, TaskStateError, reviewRevision, } from './task-state.js';
43
43
  import { getRoomRecord, RoomStateError, } from './room-state.js';
44
44
  import { createCoworkAdapter } from './cowork-adapter.js';
45
45
  import { markdownCode, markdownProse, renderMarkdownFailure, renderMarkdownList, renderMarkdownResult, roomStatus, taskStatus, } from './markdown.js';
@@ -212,6 +212,17 @@ function isKnownRoomStateMessage(message) {
212
212
  new RegExp(`^room ${SAFE_ID_WORD} close cannot move backward to [A-Za-z_]+$`, 'u'),
213
213
  ].some(pattern => pattern.test(message));
214
214
  }
215
+ /** Canonical ID for a CLI task argument holding an ID or a mission key. */
216
+ function cliTaskRef(ref, json) {
217
+ try {
218
+ return resolveTaskRef(ref);
219
+ }
220
+ catch (error) {
221
+ if (json)
222
+ die(error);
223
+ dieTaskRoom(error);
224
+ }
225
+ }
215
226
  function dieTaskRoom(e) {
216
227
  if (e instanceof TaskRoomApplicationError)
217
228
  e = taskRoomPublicError(e.code, e.fields);
@@ -282,7 +293,7 @@ function dieTaskRoom(e) {
282
293
  }
283
294
  const taskListMarkdown = (tasks, title = 'Tasks') => renderMarkdownList({
284
295
  icon: '📋', title, empty: 'No tasks found.',
285
- records: tasks.map(t => `${taskStatus(t.state)} ${markdownCode(t.task_id)} — ${markdownProse(t.title)}`
296
+ records: tasks.map(t => `${taskStatus(t.state)} ${t.key ? `${markdownCode(t.key)} ` : ''}${markdownCode(t.task_id)} — ${markdownProse(t.title)}`
286
297
  + ` — List ${markdownCode(t.list_name ?? 'default')}`
287
298
  + (t.deletion?.status === 'pending' ? ' — Deletion pending'
288
299
  + (t.deletion.error ? `: ${markdownProse(t.deletion.error)}` : '') : '')
@@ -295,6 +306,7 @@ const taskActionMarkdown = (title, task, fields = []) => {
295
306
  return renderMarkdownResult({
296
307
  icon: '📋', title,
297
308
  fields: [
309
+ ...(task.key ? [{ label: 'Key', value: task.key, kind: 'code' }] : []),
298
310
  { label: 'ID', value: task.task_id, kind: 'code' },
299
311
  { label: 'Status', value: taskStatus(task.state), kind: 'markdown' },
300
312
  { label: 'List', value: task.list_name ?? 'default', kind: 'code' },
@@ -800,16 +812,55 @@ export function registerTaskCommands(parent, cOpt) {
800
812
  }
801
813
  });
802
814
  taskCmd.command('list-create <name>')
803
- .description('create a named task list')
815
+ .description('create a named task list; its mission keys read <PREFIX>-1, <PREFIX>-2, ...')
816
+ .option('--prefix <prefix>', 'mission key prefix: 1-6 Latin letters or digits starting with a letter (default: suggested from the name)')
804
817
  .option('--json', 'JSON output')
805
818
  .action(async (name, opts) => {
806
819
  try {
807
- const list = await taskRoomService().createTaskList({ actor: { kind: 'local_control', surface: 'cli' }, name });
820
+ const list = await taskRoomService().createTaskList({ actor: { kind: 'local_control', surface: 'cli' }, name, prefix: opts.prefix });
821
+ if (opts.json) {
822
+ console.log(JSON.stringify({ schema_version: 1, list }, null, 2));
823
+ return;
824
+ }
825
+ console.log(renderMarkdownResult({ icon: '📚', title: 'Task list created', fields: [{ label: 'Name', value: list.name },
826
+ { label: 'Prefix', value: list.prefix ?? '', kind: 'code' }] }));
827
+ }
828
+ catch (e) {
829
+ if (opts.json)
830
+ die(e);
831
+ dieTaskRoom(e);
832
+ }
833
+ });
834
+ taskCmd.command('migrate-keys')
835
+ .description('assign mission keys and frozen agent names to tasks created before mission keys (idempotent)')
836
+ .option('--json', 'JSON output')
837
+ .action(async (opts) => {
838
+ try {
839
+ await taskRoomService().prepareMissionKeys();
840
+ if (opts.json) {
841
+ console.log(JSON.stringify({ schema_version: 1, migrated: true }, null, 2));
842
+ return;
843
+ }
844
+ console.log(renderMarkdownResult({ icon: '🔑', title: 'Mission keys are up to date', fields: [] }));
845
+ }
846
+ catch (e) {
847
+ if (opts.json)
848
+ die(e);
849
+ dieTaskRoom(e);
850
+ }
851
+ });
852
+ taskCmd.command('list-prefix <name> <prefix>')
853
+ .description('change the prefix for new mission keys of a list; issued keys never change')
854
+ .option('--json', 'JSON output')
855
+ .action(async (name, prefix, opts) => {
856
+ try {
857
+ const list = await taskRoomService().setTaskListPrefix({ actor: { kind: 'local_control', surface: 'cli' }, name, prefix });
808
858
  if (opts.json) {
809
859
  console.log(JSON.stringify({ schema_version: 1, list }, null, 2));
810
860
  return;
811
861
  }
812
- console.log(renderMarkdownResult({ icon: '📚', title: 'Task list created', fields: [{ label: 'Name', value: list.name }] }));
862
+ console.log(renderMarkdownResult({ icon: '📚', title: 'Task list prefix changed', fields: [{ label: 'Name', value: list.name },
863
+ { label: 'Prefix', value: list.prefix ?? '', kind: 'code' }] }));
813
864
  }
814
865
  catch (e) {
815
866
  if (opts.json)
@@ -859,6 +910,7 @@ export function registerTaskCommands(parent, cOpt) {
859
910
  .requiredOption('--list <name>', 'destination task list')
860
911
  .option('--json', 'JSON output')
861
912
  .action(async (id, opts) => {
913
+ id = cliTaskRef(id, opts.json);
862
914
  try {
863
915
  const task = await taskRoomService().moveTask({ actor: { kind: 'local_control', surface: 'cli' }, taskId: id, list: opts.list });
864
916
  if (opts.json) {
@@ -877,6 +929,7 @@ export function registerTaskCommands(parent, cOpt) {
877
929
  .description('show task details')
878
930
  .option('--json', 'JSON output')
879
931
  .action(async (id, opts) => {
932
+ id = cliTaskRef(id, opts.json);
880
933
  try {
881
934
  const service = taskRoomService(opts.configuration);
882
935
  const { task: t, orchestration: room } = service.getTask(id);
@@ -892,6 +945,7 @@ export function registerTaskCommands(parent, cOpt) {
892
945
  console.log(renderMarkdownResult({
893
946
  icon: '📋', title: 'Task details',
894
947
  fields: [
948
+ ...(t.key ? [{ label: 'Key', value: t.key, kind: 'code' }] : []),
895
949
  { label: 'ID', value: t.task_id, kind: 'code' },
896
950
  { label: 'Title', value: t.title },
897
951
  { label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
@@ -932,6 +986,7 @@ export function registerTaskCommands(parent, cOpt) {
932
986
  .description('resume proven stopped task members in-place; never create identities or replay invites')
933
987
  .option('--json', 'JSON output')
934
988
  .action(async (id, opts) => {
989
+ id = cliTaskRef(id, opts.json);
935
990
  try {
936
991
  const { recoverTaskMembers } = await import('./recovery.js');
937
992
  const task = getTask(id), cfg = loadConfig(opts.configuration);
@@ -969,6 +1024,7 @@ export function registerTaskCommands(parent, cOpt) {
969
1024
  .option('--no-loops', 'disable loops for current member')
970
1025
  .option('--json', 'JSON output')
971
1026
  .action(async (id, opts, command) => {
1027
+ id = cliTaskRef(id, opts.json);
972
1028
  try {
973
1029
  const service = taskRoomService(opts.configuration);
974
1030
  const t = await service.startTask({
@@ -1002,6 +1058,7 @@ export function registerTaskCommands(parent, cOpt) {
1002
1058
  .requiredOption('--reason <reason>', 'blocking reason')
1003
1059
  .option('--json', 'JSON output')
1004
1060
  .action((id, opts) => {
1061
+ id = cliTaskRef(id, opts.json);
1005
1062
  try {
1006
1063
  const previous = getTask(id).state;
1007
1064
  const t = taskRoomService().blockTask({
@@ -1024,6 +1081,7 @@ export function registerTaskCommands(parent, cOpt) {
1024
1081
  .description('unblock a task')
1025
1082
  .option('--json', 'JSON output')
1026
1083
  .action((id, opts) => {
1084
+ id = cliTaskRef(id, opts.json);
1027
1085
  try {
1028
1086
  const prior = getTask(id);
1029
1087
  const previous = prior.state;
@@ -1047,6 +1105,7 @@ export function registerTaskCommands(parent, cOpt) {
1047
1105
  .description('move a task to review')
1048
1106
  .option('--json', 'JSON output')
1049
1107
  .action((id, opts) => {
1108
+ id = cliTaskRef(id, opts.json);
1050
1109
  try {
1051
1110
  const previous = getTask(id).state;
1052
1111
  const t = taskRoomService().reviewTask({
@@ -1072,6 +1131,7 @@ export function registerTaskCommands(parent, cOpt) {
1072
1131
  .option('--request-key <key>', 'retry key: repeating the same key and text returns the original entry')
1073
1132
  .option('--json', 'JSON output')
1074
1133
  .action((id, opts) => {
1134
+ id = cliTaskRef(id, opts.json);
1075
1135
  try {
1076
1136
  if (opts.text !== undefined && opts.textFile !== undefined)
1077
1137
  throw new Error('use --text or --text-file, not both');
@@ -1112,6 +1172,7 @@ export function registerTaskCommands(parent, cOpt) {
1112
1172
  .option('--expected-revision <n>', 'package revision you last read; --clear is refused if it changed since')
1113
1173
  .option('--json', 'JSON output')
1114
1174
  .action(async (id, opts) => {
1175
+ id = cliTaskRef(id, opts.json);
1115
1176
  try {
1116
1177
  if (opts.markdown !== undefined && opts.markdownFile !== undefined)
1117
1178
  throw new Error('use --markdown or --markdown-file, not both');
@@ -1165,6 +1226,7 @@ export function registerTaskCommands(parent, cOpt) {
1165
1226
  .option('--summary-file <path>', 'completion summary from file')
1166
1227
  .option('--json', 'JSON output')
1167
1228
  .action(async (id, opts) => {
1229
+ id = cliTaskRef(id, opts.json);
1168
1230
  try {
1169
1231
  const previous = getTask(id).state;
1170
1232
  let summary = opts.summary;
@@ -1208,6 +1270,7 @@ export function registerTaskCommands(parent, cOpt) {
1208
1270
  try {
1209
1271
  if (id !== confirmId)
1210
1272
  throw taskRoomPublicError('task_confirmation_mismatch');
1273
+ id = resolveTaskRef(id);
1211
1274
  const previous = getTask(id).state;
1212
1275
  const plan = await taskRoomService(opts.configuration).cancelTask({
1213
1276
  actor: { kind: 'local_control', surface: 'cli' }, taskId: id,
@@ -1246,6 +1309,8 @@ export function registerTaskCommands(parent, cOpt) {
1246
1309
  try {
1247
1310
  if (id !== confirmId)
1248
1311
  throw taskRoomPublicError('task_confirmation_mismatch');
1312
+ // A deleted key still maps to its canonical ID, so a repeated delete stays idempotent.
1313
+ id = resolveTaskRef(id, { allowDeleted: true });
1249
1314
  const accepted = await taskRoomService(opts.configuration).requestTaskDeletion({
1250
1315
  actor: { kind: 'local_control', surface: 'cli' }, taskId: id,
1251
1316
  });
@@ -1293,6 +1358,7 @@ export function registerTaskCommands(parent, cOpt) {
1293
1358
  .description('internal: settle a previously accepted task terminal intent')
1294
1359
  .option('--json', 'JSON output')
1295
1360
  .action(async (id, opts) => {
1361
+ id = cliTaskRef(id, opts.json);
1296
1362
  try {
1297
1363
  const app = taskRoomService(opts.configuration);
1298
1364
  const t = await app.settleTask({
@@ -1318,6 +1384,7 @@ export function registerTaskCommands(parent, cOpt) {
1318
1384
  .description('internal: continue an accepted room-provisioning operation')
1319
1385
  .option('--json', 'JSON output')
1320
1386
  .action(async (id, opts) => {
1387
+ id = cliTaskRef(id, opts.json);
1321
1388
  try {
1322
1389
  await withFileLock(taskProvisioningWorkerLockPath(id), async () => {
1323
1390
  const app = taskRoomService(opts.configuration);
@@ -1352,6 +1419,7 @@ export function registerTaskCommands(parent, cOpt) {
1352
1419
  .option('--json', 'JSON output')
1353
1420
  .option('--operation-id <id>', 'accepted operation ID; status updates apply only while it is current')
1354
1421
  .action(async (id, operation, room, opts) => {
1422
+ id = cliTaskRef(id, opts.json);
1355
1423
  try {
1356
1424
  if (!['start', 'open', 'close-room', 'close'].includes(operation))
1357
1425
  throw new Error(`unknown layout operation: ${operation}`);
@@ -1371,6 +1439,7 @@ export function registerTaskCommands(parent, cOpt) {
1371
1439
  .description('internal: settle a previously accepted task deletion')
1372
1440
  .option('--json', 'JSON output')
1373
1441
  .action(async (id, opts) => {
1442
+ id = cliTaskRef(id, opts.json);
1374
1443
  try {
1375
1444
  const app = taskRoomService(opts.configuration);
1376
1445
  const result = await app.settleTaskDeletion({
@@ -1416,6 +1485,7 @@ export function registerTaskCommands(parent, cOpt) {
1416
1485
  .option('--no-loops', 'disable loops for current member')
1417
1486
  .option('--json', 'JSON output')
1418
1487
  .action(async (id, opts, command) => {
1488
+ id = cliTaskRef(id, opts.json);
1419
1489
  try {
1420
1490
  const previous = getTask(id).state;
1421
1491
  console.error('warning: `fleet task work` is deprecated; use `fleet task start`');
@@ -1463,6 +1533,7 @@ export function registerTaskCommands(parent, cOpt) {
1463
1533
  .option('--summary-file <path>', 'completion summary from file')
1464
1534
  .option('--json', 'JSON output')
1465
1535
  .action(async (id, opts) => {
1536
+ id = cliTaskRef(id, opts.json);
1466
1537
  try {
1467
1538
  const previous = getTask(id).state;
1468
1539
  let summary = opts.summary;
@@ -1581,6 +1652,7 @@ export function registerRoomCommands(parent, cOpt) {
1581
1652
  .description('show room details')
1582
1653
  .option('--json', 'JSON output')
1583
1654
  .action(async (id, opts) => {
1655
+ id = cliTaskRef(id, opts.json);
1584
1656
  try {
1585
1657
  const { room: cowork, orchestration: r } = await taskRoomService(opts.configuration).getRoomDetail(id);
1586
1658
  if (opts.json) {
@@ -1620,6 +1692,7 @@ export function registerRoomCommands(parent, cOpt) {
1620
1692
  .description('open room in Cowork local console')
1621
1693
  .option('--json', 'JSON output')
1622
1694
  .action(async (id, opts) => {
1695
+ id = cliTaskRef(id, opts.json);
1623
1696
  try {
1624
1697
  const { room, orchestration } = await taskRoomService(opts.configuration).getRoomDetail(id);
1625
1698
  const url = `http://localhost:4460/room/${id}`;
@@ -1646,6 +1719,7 @@ export function registerRoomCommands(parent, cOpt) {
1646
1719
  .description('show room members')
1647
1720
  .option('--json', 'JSON output')
1648
1721
  .action(async (id, opts) => {
1722
+ id = cliTaskRef(id, opts.json);
1649
1723
  try {
1650
1724
  const { orchestration: r, members } = await taskRoomService(opts.configuration).getRoomMembers(id);
1651
1725
  if (opts.json) {
@@ -1,3 +1,4 @@
1
+ import { normalizeMissionPrefix } from './mission-keys.js';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import { isAbsolute } from 'node:path';
3
4
  import { createHash } from 'node:crypto';
@@ -105,7 +106,17 @@ export function validateTasksConfig(raw, path) {
105
106
  throw new RoomsTasksConfigError(path, 'tasks.close_room_on_done: must be a boolean');
106
107
  if (raw.retain_completed_for !== undefined && typeof raw.retain_completed_for !== 'string')
107
108
  throw new RoomsTasksConfigError(path, 'tasks.retain_completed_for: must be a duration string');
109
+ let defaultPrefix;
110
+ if (raw.default_prefix !== undefined) {
111
+ try {
112
+ defaultPrefix = normalizeMissionPrefix(raw.default_prefix);
113
+ }
114
+ catch (error) {
115
+ throw new RoomsTasksConfigError(path, `tasks.default_prefix: ${error.message}`);
116
+ }
117
+ }
108
118
  return {
119
+ ...(defaultPrefix ? { default_prefix: defaultPrefix } : {}),
109
120
  default_room_template: raw.default_room_template,
110
121
  create_mode: createMode,
111
122
  close_room_on_done: raw.close_room_on_done,
@@ -1,7 +1,22 @@
1
1
  import { type TempLifecycleDeps } from '../temp-lifecycle.js';
2
+ import type { LabelCandidate } from './member-naming.js';
2
3
  import type { LayoutInstance, RoomLayoutState } from './layout.js';
3
4
  import type { RoomMemberSeat } from './types.js';
5
+ /** Name a pre-mission-key layout run derived for every participant; still the recovery fallback. */
6
+ export declare function legacyLayoutMemberName(runId: string, key: string): string;
7
+ /**
8
+ * The owned name for a layout participant: the durable allocation recorded on
9
+ * its task (fresh or frozen), else the legacy hash. This is the ownership
10
+ * proof retirement checks before its launch, CID and provenance fences.
11
+ */
4
12
  export declare function layoutOwnedMemberName(runId: string, key: string): string;
13
+ /**
14
+ * Allocate (or reuse) the participant's durable role-scoped name before it is
15
+ * spawned. Every launch goes through the task allocator, which confirms a
16
+ * reused allocation is durable before returning it; retirement uses the
17
+ * side-effect-free layoutOwnedMemberName instead.
18
+ */
19
+ export declare function allocateLayoutMemberName(runId: string, key: string, candidates: LabelCandidate[]): string;
5
20
  /** Retire a local layout factory result, never a borrowed binding. The runtime
6
21
  * instance and CID fence replacement supervisors even when already stopped.
7
22
  */
@@ -9,6 +9,8 @@ import { agentDir, stateRoot } from '../paths.js';
9
9
  import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempArchiveForCreationAction } from '../temp-lifecycle.js';
10
10
  import { assertMemberIdentityAbsent, removeExactMemberIdentity, waitForLivenessAbsent } from './close.js';
11
11
  import { assertMemberNotPermanent } from './member-ownership.js';
12
+ import { allocateTaskMemberNames, recordedMemberName } from './task-state.js';
13
+ import { assertMemberNameAvailable } from './provision.js';
12
14
  import { retireTaskMemberService } from './task-service-retirement.js';
13
15
  function proof(path) {
14
16
  const stat = lstatSync(path);
@@ -16,9 +18,32 @@ function proof(path) {
16
18
  throw Error('LAYOUT_MEMBER_UNSAFE_PROOF');
17
19
  return readFileSync(path, 'utf8');
18
20
  }
19
- export function layoutOwnedMemberName(runId, key) {
21
+ /** Name a pre-mission-key layout run derived for every participant; still the recovery fallback. */
22
+ export function legacyLayoutMemberName(runId, key) {
20
23
  return `layout-${createHash('sha256').update(runId + ':' + key).digest('hex').slice(0, 16)}`;
21
24
  }
25
+ const taskOfRun = (runId) => runId.startsWith('task-') && /^task-[0-9a-z]{9}[0-9a-f]{8}$/.test(runId) ? runId.slice(5) : undefined;
26
+ /**
27
+ * The owned name for a layout participant: the durable allocation recorded on
28
+ * its task (fresh or frozen), else the legacy hash. This is the ownership
29
+ * proof retirement checks before its launch, CID and provenance fences.
30
+ */
31
+ export function layoutOwnedMemberName(runId, key) {
32
+ const taskId = taskOfRun(runId);
33
+ return (taskId && recordedMemberName(taskId, `layout:${runId}:${key}`)) || legacyLayoutMemberName(runId, key);
34
+ }
35
+ /**
36
+ * Allocate (or reuse) the participant's durable role-scoped name before it is
37
+ * spawned. Every launch goes through the task allocator, which confirms a
38
+ * reused allocation is durable before returning it; retirement uses the
39
+ * side-effect-free layoutOwnedMemberName instead.
40
+ */
41
+ export function allocateLayoutMemberName(runId, key, candidates) {
42
+ const taskId = taskOfRun(runId);
43
+ if (!taskId)
44
+ return legacyLayoutMemberName(runId, key);
45
+ return allocateTaskMemberNames(taskId, [{ allocationKey: `layout:${runId}:${key}`, candidates }], assertMemberNameAvailable)?.[0] ?? legacyLayoutMemberName(runId, key);
46
+ }
22
47
  function assertOwned(instance, runId, key) {
23
48
  if (instance.remote || !instance.agent || instance.temporary !== true
24
49
  || instance.agent !== layoutOwnedMemberName(runId, key) || instance.supervisor !== stateRoot())
@@ -1,6 +1,7 @@
1
1
  import { type AgentTemplateDefinition } from '../config.js';
2
2
  import { RoomLayout, type LayoutInstance, type LayoutSupervisor, type RoomLayoutState } from './layout.js';
3
3
  import { type RoomLayoutDefinition } from './layout-config.js';
4
+ import type { LabelCandidate } from './member-naming.js';
4
5
  export declare class NativeLayoutSupervisor implements LayoutSupervisor {
5
6
  private configPath;
6
7
  private runId;
@@ -12,7 +13,7 @@ export declare class NativeLayoutSupervisor implements LayoutSupervisor {
12
13
  inspect(agent: string, temporary: boolean): Promise<LayoutInstance>;
13
14
  private call;
14
15
  verify(i: LayoutInstance): Promise<void>;
15
- spawn(key: string, template: string, snapshot?: AgentTemplateDefinition): Promise<LayoutInstance>;
16
+ spawn(key: string, template: string, snapshot?: AgentTemplateDefinition, labels?: LabelCandidate[]): Promise<LayoutInstance>;
16
17
  join(i: LayoutInstance, invite: string, roomCid: string, membership?: {
17
18
  roomId: string;
18
19
  role: string;
@@ -13,7 +13,7 @@ import { createCoworkAdapter } from './cowork-adapter.js';
13
13
  import { callRemoteLayoutBinding } from './layout-binding-client.js';
14
14
  import { readClientProfile } from '../client-profile.js';
15
15
  import { tempSupervisorLiveness } from '../temp-lifecycle.js';
16
- import { layoutOwnedMemberName, retireOwnedLayoutMember } from './layout-member-retirement.js';
16
+ import { allocateLayoutMemberName, retireOwnedLayoutMember, } from './layout-member-retirement.js';
17
17
  import { EMPTY_TASK_LAYOUT, emptyTaskLayout } from './empty-layout.js';
18
18
  export class NativeLayoutSupervisor {
19
19
  configPath;
@@ -48,11 +48,13 @@ export class NativeLayoutSupervisor {
48
48
  return this.request(i.agent, i.temporary, { ...request, instance: i });
49
49
  }
50
50
  async verify(i) { await this.call(i, { action: 'verify' }); }
51
- async spawn(key, template, snapshot) {
51
+ async spawn(key, template, snapshot, labels = []) {
52
52
  const definition = snapshot ?? this.templates[template];
53
53
  if (!definition)
54
54
  throw Error(`agent template not found in run snapshot: ${template}`);
55
- const name = layoutOwnedMemberName(this.runId, key);
55
+ // The first allocation is durable on the task; recovery reuses it once its durability is confirmed.
56
+ // Labels come from the run snapshot only.
57
+ const name = allocateLayoutMemberName(this.runId, key, labels);
56
58
  const taskId = this.runId.startsWith('task-') ? this.runId.slice(5) : undefined;
57
59
  const task = taskId ? (await import('./task-state.js')).getTask(taskId) : undefined;
58
60
  if (task && task.layout?.run_id !== this.runId)
@@ -196,6 +198,7 @@ export class RoomLayoutService {
196
198
  const source = scope.source ?? this.definition(name);
197
199
  const definition = scope.definition ? scope.definition(structuredClone(source)) : source;
198
200
  const templates = {};
201
+ const roleLabels = {};
199
202
  for (const [key, participant] of Object.entries(definition.participants)) {
200
203
  if (bindings[key] || !participant.agent_template)
201
204
  continue;
@@ -203,9 +206,12 @@ export class RoomLayoutService {
203
206
  if (!template)
204
207
  throw Error(`agent template not found: ${participant.agent_template}`);
205
208
  templates[participant.agent_template] = { ...structuredClone(template), ...(scope.cwd ? { cwd: scope.cwd } : {}) };
209
+ const roleRef = cfg.agentTemplateRoleRefs?.[participant.agent_template];
210
+ if (roleRef)
211
+ roleLabels[participant.agent_template] = roleRef;
206
212
  }
207
213
  const engine = this.engine(id);
208
- await engine.create(definition, layoutSupervisorId(), bindings, templates);
214
+ await engine.create(definition, layoutSupervisorId(), bindings, templates, roleLabels);
209
215
  return { id, state: engine.snapshot() };
210
216
  }
211
217
  }
@@ -53,7 +53,7 @@ export interface LayoutSupervisor {
53
53
  id: string;
54
54
  /** Must verify current launch, CID, live session and standalone ownership. */
55
55
  verify(instance: LayoutInstance): Promise<void>;
56
- spawn(key: string, template: string, definition?: import('../config.js').AgentTemplateDefinition): Promise<LayoutInstance>;
56
+ spawn(key: string, template: string, definition?: import('../config.js').AgentTemplateDefinition, labels?: import('./member-naming.js').LabelCandidate[]): Promise<LayoutInstance>;
57
57
  join(instance: LayoutInstance, invite: string, roomCid: string, membership?: {
58
58
  roomId: string;
59
59
  role: string;
@@ -84,6 +84,8 @@ export interface RoomLayoutState {
84
84
  agent_templates?: Record<string, import('../config.js').AgentTemplateDefinition>;
85
85
  definition: LayoutDefinition;
86
86
  controller: string;
87
+ /** Named Role of each source Agent Template, sealed at run creation; later config edits never rename members. */
88
+ role_labels?: Record<string, string>;
87
89
  participants: Record<string, ParticipantState>;
88
90
  rooms: Record<string, RoomState>;
89
91
  /** Runtime additions leave the pinned source definition unchanged. */
@@ -115,7 +117,7 @@ export declare class RoomLayout {
115
117
  snapshot(): RoomLayoutState;
116
118
  private save;
117
119
  private verify;
118
- create(definition: LayoutDefinition, controller: string, bindings?: Record<string, LayoutInstance>, agentTemplates?: RoomLayoutState['agent_templates']): Promise<void>;
120
+ create(definition: LayoutDefinition, controller: string, bindings?: Record<string, LayoutInstance>, agentTemplates?: RoomLayoutState['agent_templates'], roleLabels?: RoomLayoutState['role_labels']): Promise<void>;
119
121
  private checkOwnerRoles;
120
122
  private operation;
121
123
  private mutation;
@@ -16,6 +16,21 @@ function need(ok, message) { if (!ok)
16
16
  function instanceKey(definition, participant, room) {
17
17
  return definition.participants[participant]?.instance_scope === 'room' ? `${room}:${participant}` : participant;
18
18
  }
19
+ /**
20
+ * Naming labels for a participant, after its template's named Role (added by
21
+ * the supervisor): a runtime-added member's room role, else its room role when
22
+ * every room it joins agrees (or its own room when room-scoped), else its key.
23
+ */
24
+ function participantLabels(s, participant, template, room) {
25
+ const added = s.additional_members?.[participant];
26
+ // Runs created before sealing have no role_labels: fall back to room roles, never to current config.
27
+ const sealed = s.role_labels && own(s.role_labels, template) ? s.role_labels[template] : undefined;
28
+ const roles = Object.entries(s.definition.rooms)
29
+ .filter(([key, spec]) => spec.members.includes(participant) && (!room || room === key))
30
+ .map(([, spec]) => spec.roles?.[participant] ?? participant);
31
+ const agreed = added?.role ?? (new Set(roles).size === 1 ? roles[0] : undefined);
32
+ return [{ label: sealed, source: 'template_ref' }, { label: agreed, source: 'room_role' }, { label: participant, source: 'participant_key' }];
33
+ }
19
34
  export function validateLayout(def) {
20
35
  need(Object.keys(def.rooms).length, 'rooms required');
21
36
  const empty = Object.keys(def.participants).length === 0;
@@ -59,7 +74,7 @@ export class RoomLayout {
59
74
  need(i.launch && i.cid && i.session, 'incomplete instance reference');
60
75
  await this.supervisor.verify(i);
61
76
  }
62
- async create(definition, controller, bindings = {}, agentTemplates) {
77
+ async create(definition, controller, bindings = {}, agentTemplates, roleLabels) {
63
78
  validateLayout(definition);
64
79
  need(controller, 'controller required');
65
80
  this.checkOwnerRoles(definition);
@@ -90,7 +105,8 @@ export class RoomLayout {
90
105
  participants[key] = { owned: !bindings[key], ...(bindings[key] ? { instance: bindings[key] } : {}) };
91
106
  }
92
107
  this.save({ version: 1, definition, controller, ...(this.owner ? { owner: ownerReference(this.owner) } : {}),
93
- agent_templates: agentTemplates, participants, rooms: {}, order: [], closed: false });
108
+ agent_templates: agentTemplates, ...(roleLabels && Object.keys(roleLabels).length ? { role_labels: roleLabels } : {}),
109
+ participants, rooms: {}, order: [], closed: false });
94
110
  });
95
111
  }
96
112
  checkOwnerRoles(definition) {
@@ -150,9 +166,10 @@ export class RoomLayout {
150
166
  need(template, `no factory for ${member}`);
151
167
  if (this.supervisor.taskSupervised)
152
168
  p.task_supervised = true;
169
+ const labels = participantLabels(s, member, template, p.room);
153
170
  await this.mutation(s, `spawn:${resolvedKey}`, () => s.additional_members?.[member]
154
- ? this.supervisor.spawn(resolvedKey, template, s.agent_templates?.[template])
155
- : this.supervisor.spawn(resolvedKey, template), i => { p.instance = i; });
171
+ ? this.supervisor.spawn(resolvedKey, template, s.agent_templates?.[template], labels)
172
+ : this.supervisor.spawn(resolvedKey, template, undefined, labels), i => { p.instance = i; });
156
173
  await this.verify(p.instance);
157
174
  }
158
175
  }
@@ -0,0 +1,43 @@
1
+ import type { MemberNameAllocation, MemberNaming, TaskRecord } from './types.js';
2
+ export declare const AGENT_NAME_MAX = 64;
3
+ /** Ordinal suffix headroom: `-999`. */
4
+ export declare const MAX_ROLE_ORDINAL = 999;
5
+ export type LabelSource = NonNullable<MemberNameAllocation['label_source']>;
6
+ export interface LabelCandidate {
7
+ label: string | undefined;
8
+ source: LabelSource;
9
+ }
10
+ export declare class MemberNameError extends Error {
11
+ readonly code: 'role_ordinal_exhausted' | 'agent_name_taken' | 'invalid_label';
12
+ constructor(code: 'role_ordinal_exhausted' | 'agent_name_taken' | 'invalid_label', message: string);
13
+ }
14
+ /** PascalCase join of Latin alphanumeric words; diacritics folded, other scripts dropped. */
15
+ export declare function labelStem(label: string): string;
16
+ /** First candidate that survives normalization, else `Agent`. */
17
+ export declare function chooseStem(key: string, candidates: readonly LabelCandidate[]): {
18
+ stem: string;
19
+ source: LabelSource;
20
+ };
21
+ export declare const bucketOf: (stem: string) => string;
22
+ /** Own-property read: buckets such as `constructor` must never hit Object.prototype. */
23
+ export declare const ownEntry: <T>(record: Record<string, T> | undefined, key: string) => T | undefined;
24
+ export declare function formatMemberName(key: string, stem: string, ordinal: number): string;
25
+ /**
26
+ * Pure allocation step: returns the existing allocation for `allocationKey`,
27
+ * or a new one plus the updated naming. The caller persists under the task
28
+ * lock and checks external name conflicts before launch.
29
+ */
30
+ export declare function allocateName(naming: MemberNaming | undefined, key: string, allocationKey: string, candidates: readonly LabelCandidate[]): {
31
+ naming: MemberNaming;
32
+ allocation: MemberNameAllocation;
33
+ created: boolean;
34
+ };
35
+ /**
36
+ * Freeze every historical agent name and seed counters by the same label
37
+ * precedence as new names. Deduplicated by physical agent name; counters merge
38
+ * monotonically and existing allocations are never rewritten. Returns the
39
+ * input object unchanged when there is nothing to add.
40
+ */
41
+ export declare function freezeLegacyMemberNaming(task: Pick<TaskRecord, 'task_id' | 'room_id' | 'layout' | 'execution_plan' | 'member_naming'> & {
42
+ key: string;
43
+ }): MemberNaming | undefined;