@ours.network/fleet 0.19.0-nightly.13 โ†’ 0.19.0-nightly.15

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 (93) hide show
  1. package/README.md +66 -11
  2. package/dist/application/fleet-query-service.d.ts +1 -1
  3. package/dist/application/fleet-query-service.js +12 -0
  4. package/dist/application/role-creation-service.js +3 -1
  5. package/dist/application/types.d.ts +11 -0
  6. package/dist/briefing.d.ts +1 -1
  7. package/dist/briefing.js +11 -4
  8. package/dist/build-info.json +4 -4
  9. package/dist/cli.js +41 -11
  10. package/dist/config.d.ts +10 -7
  11. package/dist/config.js +32 -18
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/docs.d.ts +2 -2
  14. package/dist/docs.js +96 -24
  15. package/dist/doctor.js +10 -10
  16. package/dist/duration.js +1 -1
  17. package/dist/fleet-proxy.d.ts +5 -0
  18. package/dist/harness/acp-agent.js +11 -6
  19. package/dist/harness/claude-code.js +199 -8
  20. package/dist/harness/codex.d.ts +4 -1
  21. package/dist/harness/codex.js +70 -13
  22. package/dist/harness/types.d.ts +55 -6
  23. package/dist/isolation/bubblewrap.d.ts +4 -4
  24. package/dist/isolation/bubblewrap.js +4 -4
  25. package/dist/isolation/policy.d.ts +1 -1
  26. package/dist/isolation/policy.js +2 -2
  27. package/dist/isolation/registry.d.ts +3 -3
  28. package/dist/isolation/registry.js +5 -5
  29. package/dist/isolation/resources.d.ts +1 -1
  30. package/dist/isolation/resources.js +1 -1
  31. package/dist/isolation/types.d.ts +3 -3
  32. package/dist/loops/manager.d.ts +30 -1
  33. package/dist/loops/manager.js +69 -6
  34. package/dist/loops/state.d.ts +18 -0
  35. package/dist/loops/state.js +4 -0
  36. package/dist/model-env.d.ts +71 -0
  37. package/dist/model-env.js +106 -0
  38. package/dist/monitor.d.ts +2 -2
  39. package/dist/monitor.js +4 -4
  40. package/dist/ops.d.ts +2 -2
  41. package/dist/ops.js +9 -9
  42. package/dist/owner-channel/channel.d.ts +10 -0
  43. package/dist/owner-channel/channel.js +65 -26
  44. package/dist/owner-channel/commands.js +198 -87
  45. package/dist/owner-channel/notices.d.ts +7 -0
  46. package/dist/owner-channel/notices.js +9 -0
  47. package/dist/rooms-tasks/cli.js +453 -192
  48. package/dist/rooms-tasks/markdown.d.ts +49 -0
  49. package/dist/rooms-tasks/markdown.js +206 -0
  50. package/dist/rooms-tasks/provision.js +1 -1
  51. package/dist/runner.d.ts +48 -0
  52. package/dist/runner.js +252 -100
  53. package/dist/session/acp.d.ts +106 -2
  54. package/dist/session/acp.js +217 -14
  55. package/dist/session/activity.d.ts +31 -0
  56. package/dist/session/activity.js +48 -0
  57. package/dist/session/control.d.ts +1 -1
  58. package/dist/session/conversation-normalizer.d.ts +7 -1
  59. package/dist/session/conversation-normalizer.js +154 -11
  60. package/dist/session/conversation-store.js +1 -1
  61. package/dist/session/conversation-types.d.ts +25 -6
  62. package/dist/session/types.d.ts +35 -0
  63. package/dist/spawn.d.ts +1 -1
  64. package/dist/spawn.js +38 -26
  65. package/dist/supervisor/launchd.d.ts +2 -2
  66. package/dist/supervisor/launchd.js +4 -4
  67. package/dist/supervisor/systemd.js +4 -4
  68. package/dist/supervisor/types.d.ts +1 -1
  69. package/dist/watchdog/alerts.d.ts +2 -2
  70. package/dist/watchdog/alerts.js +2 -2
  71. package/dist/watchdog/briefing.d.ts +5 -5
  72. package/dist/watchdog/briefing.js +13 -6
  73. package/dist/watchdog/config.js +1 -1
  74. package/dist/watchdog/query.d.ts +2 -2
  75. package/dist/watchdog/query.js +2 -2
  76. package/dist/watchdog/report.js +2 -2
  77. package/dist/watchdog/run.d.ts +2 -2
  78. package/dist/watchdog/run.js +6 -6
  79. package/dist/watchdog/scheduler.d.ts +7 -7
  80. package/dist/watchdog/scheduler.js +10 -10
  81. package/dist/watchdog/service.d.ts +4 -4
  82. package/dist/watchdog/service.js +5 -6
  83. package/dist/watchdog/store.d.ts +4 -4
  84. package/dist/watchdog/store.js +4 -4
  85. package/dist/web/runtime.js +1 -1
  86. package/dist/web/server.js +1 -1
  87. package/dist/web/topology-promote.js +1 -1
  88. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js โ†’ TerminalView-C_G1ID2P.js} +1 -1
  89. package/dist/web-app/assets/{index-C3S-xFRU.js โ†’ index-BCBK78hw.js} +5 -5
  90. package/dist/web-app/index.html +1 -1
  91. package/dist/worklog.d.ts +7 -1
  92. package/dist/worklog.js +191 -39
  93. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
6
6
  import { replaceFileAtomically } from '../atomic-file.js';
7
7
  import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, interruptOutcome, } from '../session/types.js';
8
8
  import { VERSION } from '../version.js';
9
+ import { renderMarkdownFailure, renderMarkdownResult, taskStatus, } from '../rooms-tasks/markdown.js';
9
10
  import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
10
11
  import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
11
12
  import { ownerNotices, } from './notices.js';
@@ -195,10 +196,13 @@ export class OwnerChannel {
195
196
  ? ' with interruption'
196
197
  : event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
197
198
  const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
199
+ const permission = event.permissionMode
200
+ ? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
201
+ : '';
198
202
  const inherited = event.inherited.length
199
203
  ? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
200
204
  await this.sendProactiveMessage(`๐Ÿง‘โ€๐Ÿ’ป ${event.caller} spawned ${event.lifetime} agent ${event.role} `
201
- + `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
205
+ + `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
202
206
  });
203
207
  this.managementTail = run.then(() => undefined, () => undefined);
204
208
  return run;
@@ -789,17 +793,13 @@ export class OwnerChannel {
789
793
  outbox = this.outboxDir(originWireId);
790
794
  await mkdir(outbox, { recursive: true, mode: 0o700 });
791
795
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
792
- const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, outbox, admitted, group.caption), {
796
+ const queued = await this.options.session.queuePrompt(this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
793
797
  interrupt: this.options.config.interrupt,
794
798
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
795
799
  origin: { kind: 'owner', requestId,
796
800
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
797
801
  });
798
- const accepted = this.options.config.interrupt
799
- ? ownerNotices.receivedInterrupting()
800
- : queued.queuedBehind > 0
801
- ? ownerNotices.receivedQueued(queued.queuedBehind)
802
- : ownerNotices.receivedStarted();
802
+ const accepted = this.acceptanceNotice(queued);
803
803
  handledWireIds.forEach(wire => this.inFlight.add(wire));
804
804
  const receipt = this.send(sender.id, accepted, originWireId).then(() => undefined).catch(error => {
805
805
  this.logError(`attachment request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -918,7 +918,7 @@ export class OwnerChannel {
918
918
  let queued;
919
919
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
920
920
  try {
921
- queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId, outbox), {
921
+ queued = await this.options.session.queuePrompt(this.ownerPrompt(sender, text, wireId), {
922
922
  interrupt: this.options.config.interrupt,
923
923
  ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
924
924
  origin: { kind: 'owner', requestId, displayText: text },
@@ -940,14 +940,7 @@ export class OwnerChannel {
940
940
  this.state.remember(wireId);
941
941
  return true;
942
942
  }
943
- // Interrupting the live turn does not remove prompts which were already
944
- // accepted into the ACP queue. Never claim this request is running while
945
- // the session itself says earlier work remains ahead of it.
946
- const accepted = queued.queuedBehind > 0
947
- ? ownerNotices.receivedQueued(queued.queuedBehind)
948
- : this.options.config.interrupt
949
- ? ownerNotices.receivedInterrupting()
950
- : ownerNotices.receivedStarted();
943
+ const accepted = this.acceptanceNotice(queued);
951
944
  this.inFlight.add(wireId);
952
945
  const receipt = this.send(sender.id, accepted, wireId).then(() => undefined).catch(error => {
953
946
  this.logError(`request ${requestId.slice(0, 12)} acceptance notice failed`, error);
@@ -1055,7 +1048,11 @@ export class OwnerChannel {
1055
1048
  async closeRoomFromOwner(sender, roomId, wireId) {
1056
1049
  const { acceptManagedRoomClose, recordManagedRoomCloseError } = await import('../rooms-tasks/close.js');
1057
1050
  await acceptManagedRoomClose(roomId);
1058
- await this.send(sender.id, `๐Ÿ—‘๏ธ Room ${roomId} deletion accepted/pending`, wireId);
1051
+ await this.send(sender.id, renderMarkdownFailure({
1052
+ kind: 'pending', subject: `/room delete ${roomId} ${roomId}`,
1053
+ detail: 'The deletion request was accepted and is still being settled.',
1054
+ action: `Run /room recover ${roomId} if deletion remains pending.`,
1055
+ }), wireId);
1059
1056
  this.state.remember(wireId);
1060
1057
  try {
1061
1058
  await this.fleetOps.closeRoom(roomId);
@@ -1074,11 +1071,21 @@ export class OwnerChannel {
1074
1071
  taskId, kind, roomId: task.room_id, outcome,
1075
1072
  });
1076
1073
  if (accepted.terminal_intent?.status === 'settled') {
1077
- await this.send(sender.id, `โœ… Task ${taskId} โ†’ ${accepted.state}`, wireId);
1074
+ await this.send(sender.id, renderMarkdownResult({
1075
+ icon: '๐Ÿ“‹', title: 'Task terminal action complete',
1076
+ fields: [
1077
+ { label: 'ID', value: taskId, kind: 'code' },
1078
+ { label: 'Status', value: taskStatus(accepted.state), kind: 'markdown' },
1079
+ ],
1080
+ }), wireId);
1078
1081
  this.state.remember(wireId);
1079
1082
  return;
1080
1083
  }
1081
- await this.send(sender.id, `โณ Task ${taskId} terminal intent accepted/pending`, wireId);
1084
+ await this.send(sender.id, renderMarkdownFailure({
1085
+ kind: 'pending', subject: `/task ${kind === 'done' ? 'done' : 'cancel'} ${taskId}`,
1086
+ detail: 'The terminal request was accepted and is still being settled.',
1087
+ action: `Run /task recover ${taskId} if it remains pending.`,
1088
+ }), wireId);
1082
1089
  this.state.remember(wireId);
1083
1090
  try {
1084
1091
  await this.fleetOps.settleTask(taskId);
@@ -1374,6 +1381,32 @@ export class OwnerChannel {
1374
1381
  authorizationIntegrity() {
1375
1382
  return this.options.config.agent ? { ok: true } : this.authorizations.integrity();
1376
1383
  }
1384
+ /**
1385
+ * Report what the session actually did with the prompt, not what the config
1386
+ * asked for. `interrupt: true` used to be reported as "your request
1387
+ * interrupted the previous task" unconditionally; the session now answers
1388
+ * whether anything was cancelled, whether the request is queued behind
1389
+ * earlier prompts, or whether it is held until the current task reaches a
1390
+ * safe stopping point. Backends that report no delivery state keep the old
1391
+ * queuedBehind-based wording.
1392
+ */
1393
+ acceptanceNotice(queued) {
1394
+ switch (queued.delivery) {
1395
+ case 'interrupted': return ownerNotices.receivedInterrupting();
1396
+ case 'deferred': return ownerNotices.receivedDeferred();
1397
+ case 'queued': return ownerNotices.receivedQueued(Math.max(1, queued.queuedBehind));
1398
+ case 'started': return ownerNotices.receivedStarted();
1399
+ default:
1400
+ // Interrupting the live turn does not remove prompts which were already
1401
+ // accepted into the ACP queue. Never claim this request is running while
1402
+ // the session itself says earlier work remains ahead of it.
1403
+ return queued.queuedBehind > 0
1404
+ ? ownerNotices.receivedQueued(queued.queuedBehind)
1405
+ : this.options.config.interrupt
1406
+ ? ownerNotices.receivedInterrupting()
1407
+ : ownerNotices.receivedStarted();
1408
+ }
1409
+ }
1377
1410
  async complete(active, outbox, queued, activityCursor) {
1378
1411
  const progressMs = this.options.config.progress_interval_ms;
1379
1412
  let lastSeq = activityCursor;
@@ -1574,7 +1607,7 @@ export class OwnerChannel {
1574
1607
  throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
1575
1608
  return message;
1576
1609
  }
1577
- ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
1610
+ ownerAttachmentPrompt(sender, wireId, requestId, files, caption) {
1578
1611
  const lines = [
1579
1612
  '[fleet-owner]',
1580
1613
  `Authenticated owner ${safeField(sender.name, 160)} (${sender.id}) sent owner-channel attachment request ${wireId}.`,
@@ -1598,10 +1631,13 @@ export class OwnerChannel {
1598
1631
  lines.push(`- voice transcript status: ${transcription?.status ?? 'unavailable'}`, `- voice transcript fallback: audio path above; category ${transcription?.errorCategory ?? 'not_provided'}`);
1599
1632
  }
1600
1633
  }
1601
- lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.', `To attach response files, write regular files only to: ${outbox}`);
1634
+ lines.push('Answer in your final assistant response; fleet routes it only to the authenticated sender and correlates it to the originating file wire.',
1635
+ // send_file is the only delivery route an agent is given: a tool call either
1636
+ // delivers or reports an error, where a file written to disk does neither.
1637
+ 'To send the owner a file โ€” now or later in this turn โ€” call ours `send_file`:', `contact: ${this.options.config.identity}`, 'and the path of the finished file. Fleet routes it to the authenticated owner.', 'A file written anywhere else is not delivered and nothing will report that it was not.', 'Use descriptive unique filenames. Send nothing the owner did not request or should not receive.');
1602
1638
  return lines.join('\n');
1603
1639
  }
1604
- ownerPrompt(sender, text, wireId, outbox) {
1640
+ ownerPrompt(sender, text, wireId) {
1605
1641
  return [
1606
1642
  '[fleet-owner]',
1607
1643
  `Authenticated owner ${sender.name} (${sender.id}) sent owner-channel message ${wireId}.`,
@@ -1615,10 +1651,13 @@ export class OwnerChannel {
1615
1651
  ] : [
1616
1652
  'Managed-agent outbound relay is not configured; do not send intermediate or proactive owner-channel messages.',
1617
1653
  ]),
1618
- 'To attach files to your response, copy each finished file directly into this fleet outbox:',
1619
- outbox,
1620
- 'Fleet sends every regular file in that directory to the authenticated owner, correlated to this request.',
1621
- 'Use descriptive unique filenames. Put nothing there that the owner did not request or should not receive.',
1654
+ // send_file is the only delivery route an agent is given: a tool call either
1655
+ // delivers or reports an error, where a file written to disk does neither.
1656
+ 'To send the owner a file โ€” now or later in this turn โ€” call ours `send_file`:',
1657
+ `contact: ${this.options.config.identity}`,
1658
+ 'and the path of the finished file. Fleet routes it to the authenticated owner.',
1659
+ 'A file written anywhere else is not delivered and nothing will report that it was not.',
1660
+ 'Use descriptive unique filenames. Send nothing the owner did not request or should not receive.',
1622
1661
  '',
1623
1662
  text || '(empty message)',
1624
1663
  ].join('\n');
@@ -1,11 +1,92 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { launchFleetWorker } from '../rooms-tasks/external-worker.js';
3
+ import { RoomStateError } from '../rooms-tasks/room-state.js';
4
+ import { TaskStateError } from '../rooms-tasks/task-state.js';
5
+ import { markdownCode, markdownProse, renderMarkdownFailure, renderMarkdownList, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
3
6
  import { OWNER_COMMENT_LABEL, ownerNotices } from './notices.js';
4
7
  /** A malformed invocation; the dispatcher answers it with annotated help. */
5
8
  class OwnerCommandUsageError extends Error {
6
9
  }
7
10
  const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
8
11
  const REPLY_MAX_CHARS = 3_500;
12
+ const taskListRecord = (task) => `${taskStatus(task.state)} ${markdownCode(task.task_id)} โ€” ${markdownProse(task.title)}`
13
+ + (task.blocked ? ` โ€” ๐Ÿšง Blocked: ${markdownProse(task.blocked.reason)}` : '');
14
+ const roomListRecord = (room) => `${roomStatus(room.state)} ${markdownCode(room.room_id)} โ€” ${markdownProse(room.room_name)}`
15
+ + (room.task_id ? ` โ€” Task ${markdownCode(room.task_id)}` : '');
16
+ const TASK_STATE_WORD = '(?:backlog|provisioning|active|review|done|cancelled|failed)';
17
+ const SAFE_ID_WORD = '[A-Za-z0-9_-]{1,128}';
18
+ function isKnownOwnerTaskState(message) {
19
+ return [
20
+ new RegExp(`^cannot transition from '${TASK_STATE_WORD}' to '${TASK_STATE_WORD}'$`, 'u'),
21
+ new RegExp(`^task ${SAFE_ID_WORD} has a pending '(?:done|cancelled)' terminal intent$`, 'u'),
22
+ new RegExp(`^cannot (?:block|cancel) a '${TASK_STATE_WORD}' task$`, 'u'),
23
+ /^task is not blocked$/u,
24
+ new RegExp(`^task ${SAFE_ID_WORD} already has a conflicting '(?:done|cancelled)' terminal intent$`, 'u'),
25
+ new RegExp(`^task ${SAFE_ID_WORD} is already in terminal state '${TASK_STATE_WORD}'$`, 'u'),
26
+ new RegExp(`^cannot delete a '${TASK_STATE_WORD}' task; only 'done' tasks can be deleted$`, 'u'),
27
+ ].some(pattern => pattern.test(message));
28
+ }
29
+ function isKnownOwnerRoomState(message) {
30
+ return [
31
+ new RegExp(`^room ${SAFE_ID_WORD} is not closing$`, 'u'),
32
+ new RegExp(`^room ${SAFE_ID_WORD} has no recorded member .{1,128}$`, 'u'),
33
+ new RegExp(`^room ${SAFE_ID_WORD} close cannot move backward to [A-Za-z_]+$`, 'u'),
34
+ ].some(pattern => pattern.test(message));
35
+ }
36
+ function ownerTaskFailure(error) {
37
+ if (error instanceof TaskStateError) {
38
+ const missing = /^task not found: ([A-Za-z0-9_-]{1,128})$/u.exec(error.message);
39
+ if (missing)
40
+ return {
41
+ kind: 'not_found', detail: `Task ${missing[1]} was not found.`,
42
+ action: 'Run /task list to find a valid task ID.',
43
+ };
44
+ if (isKnownOwnerTaskState(error.message))
45
+ return {
46
+ kind: 'state', detail: 'The current task state does not allow that action.',
47
+ action: 'Run /task show <id> to inspect the current task state.',
48
+ };
49
+ }
50
+ return {
51
+ kind: 'unexpected',
52
+ action: 'Retry once; if it repeats, inspect the role logs.',
53
+ };
54
+ }
55
+ function ownerRoomFailure(error) {
56
+ if (error instanceof RoomStateError) {
57
+ const missing = /^room not found: ([A-Za-z0-9_-]{1,128})$/u.exec(error.message);
58
+ if (missing)
59
+ return {
60
+ kind: 'not_found', detail: `Room ${missing[1]} was not found.`,
61
+ action: 'Run /room list to find a valid room ID.',
62
+ };
63
+ if (isKnownOwnerRoomState(error.message))
64
+ return {
65
+ kind: 'state', detail: 'The current room state does not allow that action.',
66
+ action: 'Run /room show <id> to inspect the current room state.',
67
+ };
68
+ }
69
+ return {
70
+ kind: 'unexpected',
71
+ action: 'Retry once; if it repeats, inspect the role logs.',
72
+ };
73
+ }
74
+ const taskAction = (title, task, fields = []) => renderMarkdownResult({
75
+ icon: '๐Ÿ“‹', title,
76
+ fields: [
77
+ { label: 'ID', value: task.task_id, kind: 'code' },
78
+ { label: 'Status', value: taskStatus(task.state), kind: 'markdown' },
79
+ ...fields,
80
+ ],
81
+ });
82
+ const roomAction = (title, room, fields = []) => renderMarkdownResult({
83
+ icon: '๐Ÿ ', title,
84
+ fields: [
85
+ { label: 'ID', value: room.room_id, kind: 'code' },
86
+ { label: 'Status', value: roomStatus(room.state), kind: 'markdown' },
87
+ ...fields,
88
+ ],
89
+ });
9
90
  const strip = (value, max) => String(value).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, max);
10
91
  /** Like `strip`, but keeps newlines so multi-line listings stay readable. */
11
92
  const stripMultiline = (value, max) => String(value).replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ').slice(0, max);
@@ -150,13 +231,10 @@ export const ownerCommands = [
150
231
  const { listTasks } = await import('../rooms-tasks/task-state.js');
151
232
  const stateFilter = args?.trim() || undefined;
152
233
  const tasks = listTasks(stateFilter && stateFilter !== 'all' ? { state: stateFilter } : undefined);
153
- if (!tasks.length)
154
- return ctx.reply('๐Ÿ“‹ No tasks.');
155
- const lines = tasks.map(t => {
156
- const blocked = t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : '';
157
- return `${t.task_id} ${t.state}${blocked} ${t.title}`;
158
- });
159
- await ctx.reply(tail(`๐Ÿ“‹ Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
234
+ await ctx.reply(renderMarkdownList({
235
+ icon: '๐Ÿ“‹', title: 'Tasks', empty: 'No tasks found.',
236
+ records: tasks.map(taskListRecord),
237
+ }));
160
238
  },
161
239
  },
162
240
  {
@@ -174,16 +252,19 @@ export const ownerCommands = [
174
252
  const showTask = async (id) => {
175
253
  const { getTask } = await import('../rooms-tasks/task-state.js');
176
254
  const t = getTask(id);
177
- const lines = [
178
- `๐Ÿ“‹ Task: ${t.task_id}`,
179
- `Title: ${t.title}`,
180
- `State: ${t.state}${t.blocked ? ` [BLOCKED: ${t.blocked.reason}]` : ''}`,
181
- ...(t.template ? [`Template: ${t.template.name}@${t.template.version}`] : []),
182
- ...(t.room_id ? [`Room: ${t.room_id}`] : []),
183
- `Origin: ${t.origin.type}`,
184
- `Created: ${t.created_at}`,
185
- ];
186
- await ctx.reply(lines.join('\n'));
255
+ await ctx.reply(renderMarkdownResult({
256
+ icon: '๐Ÿ“‹', title: 'Task details',
257
+ fields: [
258
+ { label: 'ID', value: t.task_id, kind: 'code' },
259
+ { label: 'Title', value: t.title },
260
+ { label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
261
+ ...(t.blocked ? [{ label: 'Blocked', value: t.blocked.reason }] : []),
262
+ ...(t.template ? [{ label: 'Template', value: `${t.template.name}@${t.template.version}`, kind: 'code' }] : []),
263
+ ...(t.room_id ? [{ label: 'Room', value: t.room_id, kind: 'code' }] : []),
264
+ { label: 'Origin', value: t.origin.type },
265
+ { label: 'Created', value: t.created_at, kind: 'code' },
266
+ ],
267
+ }));
187
268
  };
188
269
  try {
189
270
  switch (sub) {
@@ -206,12 +287,11 @@ export const ownerCommands = [
206
287
  ...(noRoom ? { no_room: true } : {}),
207
288
  ...(trailingLines ? { brief: trailingLines } : {}),
208
289
  });
209
- const lines = [`Task ${t.task_id} created ยท ${t.state}`];
210
- if (template)
211
- lines.push(`Template: ${template}`);
212
- if (noRoom)
213
- lines.push('No room requested');
214
- await ctx.reply(lines.join('\n'));
290
+ await ctx.reply(taskAction('Task created', t, [
291
+ { label: 'Title', value: t.title },
292
+ ...(template ? [{ label: 'Template', value: template, kind: 'code' }] : []),
293
+ ...(noRoom ? [{ label: 'Room', value: 'Not requested' }] : []),
294
+ ]));
215
295
  break;
216
296
  }
217
297
  case 'list': {
@@ -227,20 +307,16 @@ export const ownerCommands = [
227
307
  const tasks = listTasks(stateFilter);
228
308
  if (filter === 'blocked') {
229
309
  const blocked = tasks.filter(t => t.blocked);
230
- if (!blocked.length) {
231
- await ctx.reply('๐Ÿ“‹ No blocked tasks.');
232
- break;
233
- }
234
- const lines = blocked.map(t => `${t.task_id} ${t.state} ${t.title} [BLOCKED: ${t.blocked.reason}]`);
235
- await ctx.reply(tail(`๐Ÿ“‹ Blocked tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
236
- break;
237
- }
238
- if (!tasks.length) {
239
- await ctx.reply('๐Ÿ“‹ No tasks.');
310
+ await ctx.reply(renderMarkdownList({
311
+ icon: '๐Ÿšง', title: 'Blocked tasks', empty: 'No blocked tasks found.',
312
+ records: blocked.map(taskListRecord),
313
+ }));
240
314
  break;
241
315
  }
242
- const lines = tasks.map(t => `${t.task_id} ${t.state} ${t.title}${t.blocked ? ` [BLOCKED]` : ''}`);
243
- await ctx.reply(tail(`๐Ÿ“‹ Tasks:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
316
+ await ctx.reply(renderMarkdownList({
317
+ icon: '๐Ÿ“‹', title: 'Tasks', empty: 'No tasks found.',
318
+ records: tasks.map(taskListRecord),
319
+ }));
244
320
  break;
245
321
  }
246
322
  case 'show': {
@@ -254,7 +330,7 @@ export const ownerCommands = [
254
330
  throw new OwnerCommandUsageError('usage: /task start <id>');
255
331
  const { startTask } = await import('../rooms-tasks/task-state.js');
256
332
  const t = startTask(rest[0]);
257
- await ctx.reply(`โœ… Task ${t.task_id} โ†’ provisioning`);
333
+ await ctx.reply(taskAction('Task started', t));
258
334
  break;
259
335
  }
260
336
  case 'block': {
@@ -263,7 +339,7 @@ export const ownerCommands = [
263
339
  const { blockTask } = await import('../rooms-tasks/task-state.js');
264
340
  const reason = rest.slice(1).join(' ');
265
341
  const t = blockTask(rest[0], reason);
266
- await ctx.reply(`๐Ÿšง Task ${t.task_id} blocked: ${reason}`);
342
+ await ctx.reply(taskAction('Task blocked', t, [{ label: 'Reason', value: reason }]));
267
343
  break;
268
344
  }
269
345
  case 'unblock': {
@@ -271,7 +347,7 @@ export const ownerCommands = [
271
347
  throw new OwnerCommandUsageError('usage: /task unblock <id>');
272
348
  const { unblockTask } = await import('../rooms-tasks/task-state.js');
273
349
  const t = unblockTask(rest[0]);
274
- await ctx.reply(`โœ… Task ${t.task_id} unblocked`);
350
+ await ctx.reply(taskAction('Task unblocked', t));
275
351
  break;
276
352
  }
277
353
  case 'review': {
@@ -279,7 +355,7 @@ export const ownerCommands = [
279
355
  throw new OwnerCommandUsageError('usage: /task review <id>');
280
356
  const { reviewTask } = await import('../rooms-tasks/task-state.js');
281
357
  const t = reviewTask(rest[0]);
282
- await ctx.reply(`๐Ÿ“ Task ${t.task_id} โ†’ review`);
358
+ await ctx.reply(taskAction('Task ready for review', t));
283
359
  break;
284
360
  }
285
361
  case 'done': {
@@ -300,9 +376,10 @@ export const ownerCommands = [
300
376
  throw new OwnerCommandUsageError('destructive: /task delete <id> <id> โ€” provide the task ID twice');
301
377
  const { deleteTask } = await import('../rooms-tasks/task-state.js');
302
378
  const deleted = deleteTask(rest[0]);
303
- await ctx.reply(deleted
304
- ? `๐Ÿ—‘๏ธ Task ${rest[0]} deleted from backlog`
305
- : `๐Ÿ—‘๏ธ Task ${rest[0]} already absent`);
379
+ await ctx.reply(renderMarkdownResult({
380
+ icon: '๐Ÿ—‘๏ธ', title: deleted ? 'Task deleted' : 'Task already absent',
381
+ fields: [{ label: 'ID', value: rest[0], kind: 'code' }],
382
+ }));
306
383
  break;
307
384
  }
308
385
  case 'recover': {
@@ -327,14 +404,21 @@ export const ownerCommands = [
327
404
  if (room.provisioning_detail === 'member_failed')
328
405
  hints.push(`Member failed at step ${room.saga.step_index}`);
329
406
  }
330
- const lines = [`Task ${t.task_id} ยท ${t.state}`];
331
- if (room)
332
- lines.push(`Room ${room.room_id} ยท ${room.state} ยท saga: ${room.saga.phase}`);
333
- if (hints.length)
334
- lines.push(`Hints: ${hints.join('; ')}`);
335
- else
336
- lines.push('No automated recovery actions available.');
337
- await ctx.reply(lines.join('\n'));
407
+ await ctx.reply(renderMarkdownResult({
408
+ icon: '๐Ÿ›Ÿ', title: 'Task recovery',
409
+ fields: [
410
+ { label: 'Task', value: t.task_id, kind: 'code' },
411
+ { label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
412
+ ...(room ? [
413
+ { label: 'Room', value: room.room_id, kind: 'code' },
414
+ { label: 'Room status', value: roomStatus(room.state), kind: 'markdown' },
415
+ { label: 'Saga', value: room.saga.phase, kind: 'code' },
416
+ ] : []),
417
+ ],
418
+ sections: hints.length
419
+ ? [{ heading: 'Next steps', items: hints }]
420
+ : [{ heading: 'Result', items: ['No automated recovery action is available.'] }],
421
+ }));
338
422
  break;
339
423
  }
340
424
  default:
@@ -345,7 +429,13 @@ export const ownerCommands = [
345
429
  catch (e) {
346
430
  if (e instanceof OwnerCommandUsageError)
347
431
  throw e;
348
- await ctx.reply(`โš ๏ธ ${e instanceof Error ? e.message : String(e)}`);
432
+ const failure = ownerTaskFailure(e);
433
+ await ctx.reply(renderMarkdownFailure({
434
+ kind: failure.kind,
435
+ subject: `/task ${sub}`,
436
+ ...(failure.detail ? { detail: failure.detail } : {}),
437
+ action: failure.action,
438
+ }));
349
439
  }
350
440
  },
351
441
  },
@@ -354,10 +444,10 @@ export const ownerCommands = [
354
444
  execute: noArgs('/rooms', async (ctx) => {
355
445
  const { listRoomRecords } = await import('../rooms-tasks/room-state.js');
356
446
  const rooms = listRoomRecords().filter(room => room.state === 'active' || room.state === 'provisioning');
357
- if (!rooms.length)
358
- return ctx.reply('๐Ÿ  No rooms.');
359
- const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
360
- await ctx.reply(tail(`๐Ÿ  Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
447
+ await ctx.reply(renderMarkdownList({
448
+ icon: '๐Ÿ ', title: 'Rooms', empty: 'No rooms found.',
449
+ records: rooms.map(roomListRecord),
450
+ }));
361
451
  }),
362
452
  },
363
453
  {
@@ -376,18 +466,23 @@ export const ownerCommands = [
376
466
  const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
377
467
  const r = getRoomRecord(id);
378
468
  if (!r || r.state === 'closing' || r.state === 'closed')
379
- return ctx.reply(`โš ๏ธ room not found: ${id}`);
380
- const lines = [
381
- `๐Ÿ  Room: ${r.room_id}`,
382
- `Name: ${r.room_name}`,
383
- `State: ${r.state}`,
384
- `Saga: ${r.saga.phase} (step ${r.saga.step_index})`,
385
- ...(r.task_id ? [`Task: ${r.task_id}`] : []),
386
- ...(r.provisioning_detail ? [`Detail: ${r.provisioning_detail}`] : []),
387
- ...(r.saga.error ? [`Error: ${r.saga.error}`] : []),
388
- `Created: ${r.created_at}`,
389
- ];
390
- await ctx.reply(lines.join('\n'));
469
+ return ctx.reply(renderMarkdownFailure({
470
+ kind: 'not_found', subject: `/room show ${id}`,
471
+ detail: `Room ${id} was not found.`, action: 'Run /room list to find a valid room ID.',
472
+ }));
473
+ await ctx.reply(renderMarkdownResult({
474
+ icon: '๐Ÿ ', title: 'Room details',
475
+ fields: [
476
+ { label: 'ID', value: r.room_id, kind: 'code' },
477
+ { label: 'Name', value: r.room_name },
478
+ { label: 'Status', value: roomStatus(r.state), kind: 'markdown' },
479
+ { label: 'Saga', value: `${r.saga.phase} (step ${r.saga.step_index})` },
480
+ ...(r.task_id ? [{ label: 'Task', value: r.task_id, kind: 'code' }] : []),
481
+ ...(r.provisioning_detail ? [{ label: 'Detail', value: r.provisioning_detail }] : []),
482
+ ...(r.saga.error ? [{ label: 'Last error', value: 'Provisioning failure recorded; inspect role logs.' }] : []),
483
+ { label: 'Created', value: r.created_at, kind: 'code' },
484
+ ],
485
+ }));
391
486
  };
392
487
  try {
393
488
  switch (sub) {
@@ -413,10 +508,10 @@ export const ownerCommands = [
413
508
  ...(templateSnapshot ? { template_snapshot: templateSnapshot } : {}),
414
509
  ...(roomTrailingLines ? { goal: roomTrailingLines } : {}),
415
510
  });
416
- const lines = [`๐Ÿ  Room ${r.room_id} created ยท ${r.state}`];
417
- if (templateName)
418
- lines.push(`Template: ${templateName}`);
419
- await ctx.reply(lines.join('\n'));
511
+ await ctx.reply(roomAction('Room created', r, [
512
+ { label: 'Name', value: r.room_name },
513
+ ...(templateName ? [{ label: 'Template', value: templateName, kind: 'code' }] : []),
514
+ ]));
420
515
  break;
421
516
  }
422
517
  case 'list': {
@@ -425,12 +520,10 @@ export const ownerCommands = [
425
520
  let rooms = listRoomRecords().filter(room => room.state === 'active' || room.state === 'provisioning');
426
521
  if (filter === 'active')
427
522
  rooms = rooms.filter(r => r.state === 'active');
428
- if (!rooms.length) {
429
- await ctx.reply('๐Ÿ  No rooms.');
430
- break;
431
- }
432
- const lines = rooms.map(r => `${r.room_id} ${r.state} ${r.room_name}${r.task_id ? ` (task: ${r.task_id})` : ''}`);
433
- await ctx.reply(tail(`๐Ÿ  Rooms:\n${lines.join('\n')}`, REPLY_MAX_CHARS));
523
+ await ctx.reply(renderMarkdownList({
524
+ icon: '๐Ÿ ', title: 'Rooms', empty: 'No rooms found.',
525
+ records: rooms.map(roomListRecord),
526
+ }));
434
527
  break;
435
528
  }
436
529
  case 'show': {
@@ -452,20 +545,29 @@ export const ownerCommands = [
452
545
  const { getRoomRecord } = await import('../rooms-tasks/room-state.js');
453
546
  const r = getRoomRecord(rest[0]);
454
547
  if (!r) {
455
- await ctx.reply(`โš ๏ธ room not found: ${rest[0]}`);
548
+ await ctx.reply(renderMarkdownFailure({
549
+ kind: 'not_found', subject: `/room recover ${rest[0]}`,
550
+ detail: `Room ${rest[0]} was not found.`, action: 'Run /room list to find a valid room ID.',
551
+ }));
456
552
  break;
457
553
  }
458
554
  if (r.state === 'closing' || r.state === 'closed') {
459
555
  await ctx.closeRoom(r.room_id);
460
556
  break;
461
557
  }
462
- const lines = [`๐Ÿ  Room ${r.room_id} ยท ${r.state} ยท saga: ${r.saga.phase}`];
463
- if (r.saga.error)
464
- lines.push(`Error: ${r.saga.error}`);
465
- if (r.provisioning_detail)
466
- lines.push(`Detail: ${r.provisioning_detail}`);
467
- lines.push(r.saga.error ? 'Run `ours-fleet room recover` from CLI for full recovery.' : 'No recovery needed.');
468
- await ctx.reply(lines.join('\n'));
558
+ await ctx.reply(renderMarkdownResult({
559
+ icon: '๐Ÿ›Ÿ', title: 'Room recovery',
560
+ fields: [
561
+ { label: 'Room', value: r.room_id, kind: 'code' },
562
+ { label: 'Status', value: roomStatus(r.state), kind: 'markdown' },
563
+ { label: 'Saga', value: r.saga.phase, kind: 'code' },
564
+ ...(r.saga.error ? [{ label: 'Last error', value: 'Provisioning failure recorded; inspect role logs.' }] : []),
565
+ ...(r.provisioning_detail ? [{ label: 'Detail', value: r.provisioning_detail }] : []),
566
+ ],
567
+ sections: [{ heading: 'Next step', items: [r.saga.error
568
+ ? 'Run ours-fleet room recover from the CLI for full recovery.'
569
+ : 'No recovery action is needed.'] }],
570
+ }));
469
571
  break;
470
572
  }
471
573
  default:
@@ -476,7 +578,13 @@ export const ownerCommands = [
476
578
  catch (e) {
477
579
  if (e instanceof OwnerCommandUsageError)
478
580
  throw e;
479
- await ctx.reply(`โš ๏ธ ${e instanceof Error ? e.message : String(e)}`);
581
+ const failure = ownerRoomFailure(e);
582
+ await ctx.reply(renderMarkdownFailure({
583
+ kind: failure.kind,
584
+ subject: `/room ${sub}`,
585
+ ...(failure.detail ? { detail: failure.detail } : {}),
586
+ action: failure.action,
587
+ }));
480
588
  }
481
589
  },
482
590
  },
@@ -575,7 +683,10 @@ export async function dispatchOwnerCommand(text, ctx) {
575
683
  }
576
684
  catch (error) {
577
685
  if (error instanceof OwnerCommandUsageError)
578
- return ctx.reply(ownerCommandHelp(error.message));
686
+ return ctx.reply(renderMarkdownFailure({
687
+ kind: 'usage', subject: token, detail: error.message,
688
+ action: `Use ${command.usage ?? `/${command.name}`} or run /help.`,
689
+ }));
579
690
  // The failure notice carries no internal detail; the channel logs it.
580
691
  await ctx.reply(ownerNotices.commandFailed(command.usage ?? `/${command.name}`));
581
692
  throw error;
@@ -22,6 +22,13 @@ export declare const ownerNotices: {
22
22
  receivedStarted: () => string;
23
23
  receivedQueued: (queuedBehind: number) => string;
24
24
  receivedInterrupting: () => string;
25
+ /**
26
+ * The honest answer when the agent is mid-task and pre-empting it would have
27
+ * corrupted the conversation. Says "not started yet" rather than borrowing
28
+ * `receivedInterrupting`'s claim that something was cancelled for this
29
+ * request.
30
+ */
31
+ receivedDeferred: () => string;
25
32
  status: (role: string, snapshot: SessionSnapshot) => string;
26
33
  interrupted: (role: string) => string;
27
34
  /** The turn IS cancelled โ€” say how, without implying the owner must retry. */