@ours.network/fleet 1.0.4 → 1.1.0-nightly.1

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 (91) hide show
  1. package/README.md +41 -50
  2. package/dist/application/capabilities.d.ts +0 -1
  3. package/dist/application/capabilities.js +3 -13
  4. package/dist/application/errors.d.ts +1 -1
  5. package/dist/application/fleet-query-service.d.ts +0 -3
  6. package/dist/application/fleet-query-service.js +1 -24
  7. package/dist/application/role-creation-service.d.ts +5 -5
  8. package/dist/application/role-creation-service.js +5 -7
  9. package/dist/application/role-repository.d.ts +1 -2
  10. package/dist/application/role-repository.js +7 -7
  11. package/dist/application/session-control.d.ts +0 -12
  12. package/dist/application/session-control.js +0 -42
  13. package/dist/application/session-mutations.d.ts +5 -5
  14. package/dist/application/task-room-service.d.ts +37 -2
  15. package/dist/application/task-room-service.js +49 -7
  16. package/dist/application/types.d.ts +0 -7
  17. package/dist/atomic-file.d.ts +5 -3
  18. package/dist/atomic-file.js +32 -8
  19. package/dist/briefing.js +1 -4
  20. package/dist/build-info.json +5 -5
  21. package/dist/cli.js +9 -23
  22. package/dist/config.d.ts +7 -7
  23. package/dist/config.js +13 -34
  24. package/dist/docs.d.ts +1 -1
  25. package/dist/docs.js +46 -30
  26. package/dist/doctor.js +0 -7
  27. package/dist/fleet-proxy.d.ts +1 -1
  28. package/dist/harness/acp-session-transport.d.ts +8 -0
  29. package/dist/harness/acp-session-transport.js +3 -0
  30. package/dist/harness/agent-session.d.ts +33 -0
  31. package/dist/harness/agent-session.js +1 -0
  32. package/dist/harness/claude-code-session.d.ts +18 -0
  33. package/dist/harness/claude-code-session.js +28 -0
  34. package/dist/harness/claude-code.d.ts +2 -1
  35. package/dist/harness/claude-code.js +29 -81
  36. package/dist/harness/codex-session.d.ts +18 -0
  37. package/dist/harness/codex-session.js +28 -0
  38. package/dist/harness/codex.d.ts +2 -1
  39. package/dist/harness/codex.js +35 -45
  40. package/dist/harness/types.d.ts +6 -38
  41. package/dist/index.d.ts +3 -5
  42. package/dist/index.js +1 -3
  43. package/dist/isolation/resources.d.ts +3 -7
  44. package/dist/isolation/resources.js +3 -7
  45. package/dist/model-env.d.ts +2 -3
  46. package/dist/model-env.js +2 -3
  47. package/dist/monitor.d.ts +1 -24
  48. package/dist/monitor.js +5 -140
  49. package/dist/owner-channel/attachments.d.ts +2 -4
  50. package/dist/owner-channel/attachments.js +6 -39
  51. package/dist/owner-channel/channel.d.ts +2 -2
  52. package/dist/owner-channel/channel.js +16 -3
  53. package/dist/owner-channel/commands.d.ts +18 -1
  54. package/dist/owner-channel/commands.js +73 -4
  55. package/dist/rooms-tasks/cli.js +121 -2
  56. package/dist/rooms-tasks/task-lists.d.ts +19 -0
  57. package/dist/rooms-tasks/task-lists.js +96 -0
  58. package/dist/rooms-tasks/task-state.d.ts +3 -0
  59. package/dist/rooms-tasks/task-state.js +281 -175
  60. package/dist/rooms-tasks/types.d.ts +11 -1
  61. package/dist/runner.d.ts +5 -15
  62. package/dist/runner.js +26 -102
  63. package/dist/session/acp.d.ts +2 -2
  64. package/dist/session/acp.js +2 -3
  65. package/dist/session/activity.d.ts +2 -3
  66. package/dist/session/activity.js +2 -3
  67. package/dist/session/arbiter.d.ts +4 -4
  68. package/dist/session/control.d.ts +3 -3
  69. package/dist/session/types.d.ts +11 -5
  70. package/dist/spawn.js +8 -9
  71. package/dist/supervisor/none.d.ts +1 -1
  72. package/dist/supervisor/none.js +2 -2
  73. package/dist/tmux.d.ts +1 -14
  74. package/dist/tmux.js +1 -51
  75. package/dist/watchdog/config.js +6 -4
  76. package/dist/watchdog/run.js +8 -23
  77. package/dist/web/auth.d.ts +1 -1
  78. package/dist/web/runtime.js +6 -15
  79. package/dist/web/server.d.ts +3 -3
  80. package/dist/web/server.js +89 -15
  81. package/dist/web/topology-promote.js +2 -5
  82. package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
  83. package/dist/web-app/assets/{index-BCBK78hw.js → index-DhMDVRU1.js} +6 -6
  84. package/dist/web-app/index.html +2 -2
  85. package/package.json +3 -8
  86. package/dist/session/tmux.d.ts +0 -28
  87. package/dist/session/tmux.js +0 -80
  88. package/dist/web/terminal/bridge.d.ts +0 -27
  89. package/dist/web/terminal/bridge.js +0 -317
  90. package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
  91. package/dist/web-app/assets/index-DuC-xnX4.css +0 -1
package/dist/monitor.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
- import { classifyFailureText } from './model-recovery.js';
5
4
  // Code constants rather than user configuration.
6
5
  const DEFAULT_PORT = 3050;
7
6
  // The daemon normally holds for 25s, but that value is operator-configurable
@@ -522,7 +521,11 @@ export class Monitor {
522
521
  }
523
522
  async deliver(pid, batch) {
524
523
  const line = formatNotificationLine(batch);
525
- if (this.deps.delivery) {
524
+ if (!this.deps.delivery) {
525
+ this.degrade('delivery', 'structured agent-session delivery is unavailable');
526
+ return false;
527
+ }
528
+ {
526
529
  const result = await this.deps.delivery.submit(line, { interrupt: this.cfg.interrupt });
527
530
  if (!result.succeeded) {
528
531
  // Name the reason: "refused" and "cancelled" are the agent's answer,
@@ -540,101 +543,6 @@ export class Monitor {
540
543
  this.recordTurn('completed');
541
544
  return true;
542
545
  }
543
- // Tmux exposes no authenticated tool lifecycle. `after_tool` therefore
544
- // degrades to the existing non-cancelling injection path; never guess a
545
- // boundary from pane text and never send C-c for this mode.
546
- if (this.cfg.interrupt === true)
547
- await this.deps.tmux.sendKey(this.name, 'C-c');
548
- if (this.cfg.interrupt === 'after_tool')
549
- this.degrade('safe-boundary', 'after_tool unsupported by tmux; using non-cancelling delivery');
550
- const state = await this.awaitInjectable(pid);
551
- if (state !== 'ready') {
552
- if (state === 'offline')
553
- this.degrade('offline', 'offline during delivery');
554
- else if (state === 'modal')
555
- this.degrade('modal', `modal wedge — pane held a dialog for ` +
556
- `${MODAL_GIVE_UP_MS / 1000}s, wake not injected`);
557
- return false;
558
- }
559
- await this.clearComposer(); // start from an empty composer
560
- await this.deps.tmux.sendText(this.name, line); // send-keys -l + Enter
561
- let delivered = false;
562
- // Verify submission for THIS line even if stop() arrives mid-flight: the text
563
- // is already in the composer and we want it submitted (at-least-once). A truly
564
- // dead pane makes safeCapture return '' ⇒ not-in-composer ⇒ breaks, no wasted Enter.
565
- for (let i = 0; i < MAX_ENTER_RETRIES;) {
566
- await this.deps.sleep(POST_VERIFY_MS);
567
- const capture = await safeCapture(this.deps.tmux, this.name);
568
- if (!capture.ok) {
569
- this.degrade('delivery', 'capture failed during injection verification');
570
- return false;
571
- }
572
- // A dialog can appear after the initial send. Never let a verification
573
- // retry confirm it. Wait under the same bounded modal policy as initial
574
- // injection, then re-capture immediately before considering Enter.
575
- if (looksModal(capture.pane)) {
576
- const state = await this.awaitInjectable(pid);
577
- if (state === 'ready')
578
- continue;
579
- if (state === 'offline')
580
- this.degrade('offline', 'offline during injection verification');
581
- else if (state === 'modal')
582
- this.degrade('modal', `modal wedge during injection verification — ` +
583
- `no Enter sent for ${MODAL_GIVE_UP_MS / 1000}s`);
584
- return false;
585
- }
586
- if (!stillInComposer(capture.pane, line)) {
587
- delivered = true;
588
- break;
589
- }
590
- await this.deps.tmux.sendKey(this.name, 'Enter');
591
- i++;
592
- }
593
- if (!delivered) {
594
- this.degrade('delivery', 'injection unverified');
595
- return false;
596
- }
597
- this.recover('delivery', 'modal');
598
- // The wake landed and a turn started; observe how that turn terminates so a
599
- // refusal-wedge (every turn dies with `API Error:` while delivery stays green)
600
- // becomes visible in `.monitor-status` instead of masquerading as armed (#19).
601
- const recoveryTriggered = await this.observeTurnOutcome(pid);
602
- return !recoveryTriggered;
603
- }
604
- /**
605
- * Watch the pane until the just-triggered turn settles, then fold its outcome
606
- * into the API-error streak and republish `.monitor-status`. A completed turn
607
- * (or an inconclusive give-up) resets/keeps the streak; an `API Error:` tail
608
- * grows it. Once the streak reaches the threshold the status degrades; a later
609
- * completed turn flips it back to armed. Detection only — no remediation (#19).
610
- */
611
- async observeTurnOutcome(pid) {
612
- for (let i = 0; i < TURN_OBSERVE_POLLS; i++) {
613
- if (this.stopped)
614
- return false; // shutting down — leave status
615
- if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
616
- return false; // loop marks offline
617
- const capture = await safeCapture(this.deps.tmux, this.name);
618
- if (!capture.ok) {
619
- this.degrade('delivery', 'capture failed during turn observation');
620
- return false;
621
- }
622
- if (looksApiError(capture.pane)) {
623
- const evidence = classifyFailureText(capture.pane);
624
- const recoveryTriggered = evidence
625
- ? this.deps.onFailureEvidence?.(evidence) === true
626
- : false;
627
- this.recordTurn('api-error');
628
- return recoveryTriggered;
629
- }
630
- if (!looksRunning(capture.pane)) {
631
- this.recordTurn('completed');
632
- return false;
633
- }
634
- await this.deps.sleep(TURN_OBSERVE_INTERVAL_MS);
635
- }
636
- this.recordTurn('inconclusive'); // still running at give-up: hold the streak, don't re-arm
637
- return false;
638
546
  }
639
547
  /** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
640
548
  recordTurn(outcome) {
@@ -658,41 +566,6 @@ export class Monitor {
658
566
  * the composer is cleared by hand. Best-effort: a dead pane just makes the keys
659
567
  * no-ops (delivery is still verified downstream).
660
568
  */
661
- async clearComposer() {
662
- for (const key of COMPOSER_CLEAR_KEYS)
663
- await this.deps.tmux.sendKey(this.name, key);
664
- }
665
- /**
666
- * Block until the console can accept input; classify offline/stopped/ready, or
667
- * `modal` when the pane still looks modal after `MODAL_GIVE_UP_MS`. The bound is
668
- * what keeps a modal from wedging delivery silently: we still never `Enter` into
669
- * the dialog, but the give-up is reported instead of retried forever.
670
- */
671
- async awaitInjectable(pid) {
672
- let modalWaits = 0;
673
- for (;;) {
674
- if (this.stopped)
675
- return 'stopped';
676
- if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
677
- return 'offline';
678
- const now = this.deps.now();
679
- if (now < this.bootDeadline) {
680
- await this.deps.sleep(this.bootDeadline - now);
681
- continue;
682
- }
683
- const capture = await safeCapture(this.deps.tmux, this.name);
684
- if (!capture.ok) {
685
- this.degrade('delivery', 'capture failed while checking session readiness');
686
- await this.deps.sleep(MODAL_RETRY_MS);
687
- continue;
688
- }
689
- if (!looksModal(capture.pane))
690
- return 'ready';
691
- if (modalWaits++ >= MAX_MODAL_WAITS)
692
- return 'modal';
693
- await this.deps.sleep(MODAL_RETRY_MS);
694
- }
695
- }
696
569
  async doFetch(since, timeoutMs, timeoutKind) {
697
570
  const ctrl = new AbortController();
698
571
  this.currentAbort = ctrl;
@@ -820,12 +693,4 @@ export class Monitor {
820
693
  export function createMonitor(o) {
821
694
  return new Monitor(o);
822
695
  }
823
- async function safeCapture(tmux, name) {
824
- try {
825
- return { ok: true, pane: await tmux.capture(name) };
826
- }
827
- catch {
828
- return { ok: false, pane: '' };
829
- }
830
- }
831
696
  const msg = (e) => e?.message ?? String(e);
@@ -55,13 +55,11 @@ export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | unde
55
55
  export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
56
56
  /**
57
57
  * Managed-agent -> owner egress limits. This intentionally does not consult
58
- * `enabled` or `allowed_mime`: those are owner -> agent admission policy.
58
+ * `enabled`: that is owner -> agent admission policy.
59
59
  */
60
60
  export declare function validateAttachmentRelaySelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
61
61
  export declare function prepareAttachmentDirectory(root: string, requestId: string): Promise<string>;
62
- export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
63
- mimePolicy?: 'strict' | 'report-only';
64
- }): Promise<AdmittedAttachment[]>;
62
+ export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig): Promise<AdmittedAttachment[]>;
65
63
  /** Injectable short-write seam, so partial writes are provably handled. */
66
64
  export interface AttachmentWriteDeps {
67
65
  write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
@@ -94,16 +94,9 @@ function parseTranscription(value, wireId) {
94
94
  fileWireId: wireId,
95
95
  } };
96
96
  }
97
- // Voice notes ride "<base>; x-ours-kind=voice-message" verbatim end to end
98
- // (the recorder's real container varies: audio/webm Chrome/Android, audio/mp4
99
- // iOS Safari, audio/ogg fallback), so policy for voice messages applies to the
100
- // base container type. Ordinary files keep exact allowlist matching.
101
97
  function baseMime(mime) {
102
98
  return mime.split(';')[0].trim();
103
99
  }
104
- function policyMime(file) {
105
- return file.kind === 'voice_message' ? baseMime(file.mime) : file.mime;
106
- }
107
100
  export function validateAttachmentSelection(files, config) {
108
101
  if (!config.enabled)
109
102
  return 'attachments are disabled for this owner channel';
@@ -111,11 +104,6 @@ export function validateAttachmentSelection(files, config) {
111
104
  return `the request exceeds the ${config.max_files_per_request}-file limit`;
112
105
  let total = 0;
113
106
  for (const file of files) {
114
- const mime = policyMime(file);
115
- if (file.kind === 'voice_message' && !mime.startsWith('audio/'))
116
- return `voice-message MIME type ${file.mime || '(missing)'} is not an audio container`;
117
- if (!config.allowed_mime.includes(mime))
118
- return `MIME type ${file.mime || '(missing)'} is not allowed`;
119
107
  if (file.size > config.max_file_bytes)
120
108
  return `a file exceeds the ${config.max_file_bytes}-byte limit`;
121
109
  total += file.size;
@@ -126,7 +114,7 @@ export function validateAttachmentSelection(files, config) {
126
114
  }
127
115
  /**
128
116
  * Managed-agent -> owner egress limits. This intentionally does not consult
129
- * `enabled` or `allowed_mime`: those are owner -> agent admission policy.
117
+ * `enabled`: that is owner -> agent admission policy.
130
118
  */
131
119
  export function validateAttachmentRelaySelection(files, config) {
132
120
  if (files.length > config.max_files_per_request)
@@ -157,7 +145,7 @@ export async function prepareAttachmentDirectory(root, requestId) {
157
145
  await chmod(dir, 0o700);
158
146
  return dir;
159
147
  }
160
- export async function admitAttachments(files, dir, config, options = {}) {
148
+ export async function admitAttachments(files, dir, config) {
161
149
  const admitted = [];
162
150
  let total = 0;
163
151
  for (let index = 0; index < files.length; index++) {
@@ -184,11 +172,8 @@ export async function admitAttachments(files, dir, config, options = {}) {
184
172
  total += bytes.length;
185
173
  if (total > config.max_request_bytes)
186
174
  throw new Error('retrieved attachments exceed the request size limit');
187
- const declaredMime = policyMime(file);
188
- const detectedMime = detectMime(bytes, declaredMime);
189
- if ((options.mimePolicy ?? 'strict') === 'strict'
190
- && !mimeCompatible(declaredMime, detectedMime))
191
- throw new Error(`retrieved attachment content does not match declared MIME ${declaredMime}`);
175
+ const declaredMime = file.mime;
176
+ const detectedMime = detectMime(bytes, baseMime(declaredMime));
192
177
  const filename = sanitizeFilename(file.filename);
193
178
  const finalPath = join(dir, `${index + 1}-${file.wireId.slice(0, 12)}-${filename}`);
194
179
  const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
@@ -417,6 +402,8 @@ export function sanitizeFilename(value) {
417
402
  return clean || 'attachment.bin';
418
403
  }
419
404
  function detectMime(bytes, declared) {
405
+ if (bytes.length === 0)
406
+ return 'application/octet-stream';
420
407
  if (bytes.subarray(0, 5).toString() === '%PDF-')
421
408
  return 'application/pdf';
422
409
  if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
@@ -443,27 +430,7 @@ function detectMime(bytes, declared) {
443
430
  return 'application/x-cfb';
444
431
  const text = bytes.toString('utf8');
445
432
  if (!text.includes('\ufffd') && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(text)) {
446
- if (declared === 'application/json') {
447
- try {
448
- JSON.parse(text);
449
- return 'application/json';
450
- }
451
- catch { }
452
- }
453
433
  return 'text/plain';
454
434
  }
455
435
  return 'application/octet-stream';
456
436
  }
457
- function mimeCompatible(declared, detected) {
458
- if (declared === detected)
459
- return true;
460
- if (['audio/wav', 'audio/x-wav'].includes(declared) && detected === 'audio/wav')
461
- return true;
462
- if (detected === 'application/zip' && declared.startsWith('application/vnd.openxmlformats-officedocument.'))
463
- return true;
464
- if (detected === 'application/x-cfb' && [
465
- 'application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint',
466
- ].includes(declared))
467
- return true;
468
- return false;
469
- }
@@ -1,5 +1,5 @@
1
1
  import { type OwnerChannelConfig } from '../config.js';
2
- import { type SessionHandle } from '../session/types.js';
2
+ import { type AgentSession } from '../session/types.js';
3
3
  import { type OwnerFleetOps } from './commands.js';
4
4
  import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
5
5
  import { type OursOps } from './ours-client.js';
@@ -12,7 +12,7 @@ export interface OwnerChannelOptions {
12
12
  /** Harness id of the role (e.g. 'claude-code', 'codex'); gates which slash commands may be forwarded. */
13
13
  harness: string;
14
14
  config: OwnerChannelConfig;
15
- session: SessionHandle;
15
+ session: AgentSession;
16
16
  stateDir: string;
17
17
  env?: Record<string, string>;
18
18
  log(line: string): void;
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { DEFAULT_OWNER_ATTACHMENT_MIME, canonicalCid, } from '../config.js';
5
+ import { canonicalCid } from '../config.js';
6
6
  import { replaceFileAtomically } from '../atomic-file.js';
7
7
  import { TaskRoomApplicationService } from '../application/task-room-service.js';
8
8
  import { RoleLifecycleService } from '../application/role-command-service.js';
@@ -109,7 +109,6 @@ export class OwnerChannel {
109
109
  this.attachmentConfig = options.config.attachments ?? {
110
110
  enabled: true, max_files_per_request: 4, max_file_bytes: 10 * 1024 * 1024,
111
111
  max_request_bytes: 20 * 1024 * 1024, retention_ms: 24 * 60 * 60 * 1_000,
112
- allowed_mime: [...DEFAULT_OWNER_ATTACHMENT_MIME],
113
112
  };
114
113
  const integrity = this.authorizationIntegrity();
115
114
  if (!integrity.ok)
@@ -1018,6 +1017,20 @@ export class OwnerChannel {
1018
1017
  actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1019
1018
  }),
1020
1019
  listTasks: filter => new TaskRoomApplicationService(this.options.configPath).listTasks(filter),
1020
+ groupedTasks: filter => new TaskRoomApplicationService(this.options.configPath).groupedTasks(filter),
1021
+ listTaskLists: () => new TaskRoomApplicationService(this.options.configPath).listTaskLists(),
1022
+ createTaskList: name => new TaskRoomApplicationService(this.options.configPath).createTaskList({
1023
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name,
1024
+ }),
1025
+ renameTaskList: (name, newName) => new TaskRoomApplicationService(this.options.configPath).renameTaskList({
1026
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name, newName,
1027
+ }),
1028
+ deleteTaskList: (name, destination) => new TaskRoomApplicationService(this.options.configPath).deleteTaskList({
1029
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, name, destination,
1030
+ }),
1031
+ moveTask: (taskId, list) => new TaskRoomApplicationService(this.options.configPath).moveTask({
1032
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, list,
1033
+ }),
1021
1034
  getTask: taskId => new TaskRoomApplicationService(this.options.configPath).getTask(taskId),
1022
1035
  blockTask: (taskId, reason) => new TaskRoomApplicationService(this.options.configPath).blockTask({
1023
1036
  actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId, reason,
@@ -1349,7 +1362,7 @@ export class OwnerChannel {
1349
1362
  }
1350
1363
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
1351
1364
  retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
1352
- const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig, { mimePolicy: 'report-only' });
1365
+ const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
1353
1366
  const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
1354
1367
  const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
1355
1368
  try {
@@ -1,5 +1,5 @@
1
1
  import type { InterruptOutcome, SessionEvent, SessionSnapshot } from '../session/types.js';
2
- import type { RoomOrchestrationRecord, TaskOutcome, TaskRecord, TaskTerminalIntent } from '../rooms-tasks/types.js';
2
+ import type { RoomOrchestrationRecord, TaskOutcome, TaskRecord, TaskTerminalIntent, TaskListRecord } from '../rooms-tasks/types.js';
3
3
  import { type OwnerCommentsState } from './notices.js';
4
4
  import type { CreateRoomRequest, CreateTaskRequest, TaskRoomApplicationService } from '../application/task-room-service.js';
5
5
  import type { TaskState } from '../rooms-tasks/types.js';
@@ -58,7 +58,24 @@ export interface OwnerCommandContext {
58
58
  startTask(taskId: string): Promise<TaskRecord>;
59
59
  listTasks(filter?: {
60
60
  state?: TaskState | TaskState[];
61
+ list?: string;
61
62
  }): TaskRecord[];
63
+ groupedTasks(filter?: {
64
+ state?: TaskState | TaskState[];
65
+ list?: string;
66
+ }): Array<{
67
+ list: TaskListRecord;
68
+ tasks: TaskRecord[];
69
+ }>;
70
+ listTaskLists(): TaskListRecord[];
71
+ createTaskList(name: string): Promise<TaskListRecord>;
72
+ renameTaskList(name: string, newName: string): Promise<TaskListRecord>;
73
+ deleteTaskList(name: string, destination?: string): Promise<{
74
+ deleted: TaskListRecord;
75
+ moved: number;
76
+ destination?: TaskListRecord;
77
+ }>;
78
+ moveTask(taskId: string, list: string): Promise<TaskRecord>;
62
79
  getTask(taskId: string): {
63
80
  task: TaskRecord;
64
81
  orchestration: RoomOrchestrationRecord | undefined;
@@ -5,12 +5,14 @@ import { TaskStateError } from '../rooms-tasks/task-state.js';
5
5
  import { markdownCode, markdownProse, renderMarkdownFailure, renderMarkdownList, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
6
6
  import { OWNER_COMMENT_LABEL, ownerNotices } from './notices.js';
7
7
  import { TaskRoomApplicationError } from '../application/task-room-service.js';
8
+ import { TaskListError } from '../rooms-tasks/task-lists.js';
8
9
  /** A malformed invocation; the dispatcher answers it with annotated help. */
9
10
  class OwnerCommandUsageError extends Error {
10
11
  }
11
12
  const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
12
13
  const REPLY_MAX_CHARS = 3_500;
13
14
  const taskListRecord = (task) => `${taskStatus(task.state)} ${markdownCode(task.task_id)} — ${markdownProse(task.title)}`
15
+ + ` — List ${markdownCode(task.list_name ?? 'default')}`
14
16
  + (task.blocked ? ` — 🚧 Blocked: ${markdownProse(task.blocked.reason)}` : '');
15
17
  const roomListRecord = (room) => `${roomStatus(room.state)} ${markdownCode(room.room_id)} — ${markdownProse(room.room_name)}`
16
18
  + (room.task_id ? ` — Task ${markdownCode(room.task_id)}` : '');
@@ -35,6 +37,14 @@ function isKnownOwnerRoomState(message) {
35
37
  ].some(pattern => pattern.test(message));
36
38
  }
37
39
  function ownerTaskFailure(error) {
40
+ if (error instanceof TaskListError)
41
+ return {
42
+ kind: error.code === 'list_not_found' ? 'not_found'
43
+ : error.code === 'invalid_name' ? 'usage' : 'state',
44
+ detail: error.message,
45
+ action: error.code === 'list_not_found' ? 'Run /task lists to find a valid list.'
46
+ : 'Choose a valid list name or an explicit different destination.',
47
+ };
38
48
  if (error instanceof TaskStateError) {
39
49
  const missing = /^task not found: ([A-Za-z0-9_-]{1,128})$/u.exec(error.message);
40
50
  if (missing)
@@ -263,6 +273,7 @@ export const ownerCommands = [
263
273
  { label: 'ID', value: t.task_id, kind: 'code' },
264
274
  { label: 'Title', value: t.title },
265
275
  { label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
276
+ { label: 'List', value: t.list_name ?? 'default', kind: 'code' },
266
277
  ...(t.blocked ? [{ label: 'Blocked', value: t.blocked.reason }] : []),
267
278
  ...(t.template ? [{ label: 'Template', value: `${t.template.name}@${t.template.version}`, kind: 'code' }] : []),
268
279
  ...(t.room_id ? [{ label: 'Room', value: t.room_id, kind: 'code' }] : []),
@@ -280,12 +291,15 @@ export const ownerCommands = [
280
291
  const tplFlag = rest.find(r => r.startsWith('--template='));
281
292
  const template = tplFlag ? tplFlag.slice('--template='.length) : undefined;
282
293
  const noRoom = rest.includes('--no-room');
283
- const titleParts = rest.filter(r => r !== '--backlog' && !r.startsWith('--template=') && r !== '--no-room');
294
+ const listFlag = rest.find(r => r.startsWith('--list='));
295
+ const list = listFlag?.slice('--list='.length);
296
+ const titleParts = rest.filter(r => r !== '--backlog' && !r.startsWith('--template=')
297
+ && !r.startsWith('--list=') && r !== '--no-room');
284
298
  if (!titleParts.length)
285
299
  throw new OwnerCommandUsageError('usage: /task create [--backlog] [--template=<name>|--no-room] <title>');
286
300
  const t = await ctx.createTask({
287
301
  title: titleParts.join(' '), origin: { type: 'owner_channel' },
288
- backlog, template, noRoom, brief: trailingLines,
302
+ backlog, template, noRoom, brief: trailingLines, list,
289
303
  });
290
304
  await ctx.reply(taskAction('Task created', t, [
291
305
  { label: 'Title', value: t.title },
@@ -295,7 +309,10 @@ export const ownerCommands = [
295
309
  break;
296
310
  }
297
311
  case 'list': {
298
- const filter = rest[0];
312
+ const listFlag = rest.find(r => r.startsWith('--list='));
313
+ const list = trailingLines ?? listFlag?.slice('--list='.length);
314
+ const grouped = rest.includes('--group-by-list');
315
+ const filter = rest.find(r => !r.startsWith('--'));
299
316
  const stateMap = {
300
317
  backlog: 'backlog', active: 'active', blocked: 'active',
301
318
  done: 'done', all: ['backlog', 'provisioning', 'active', 'review', 'done', 'cancelled', 'failed'],
@@ -303,7 +320,8 @@ export const ownerCommands = [
303
320
  const stateFilter = filter && stateMap[filter]
304
321
  ? { state: stateMap[filter] }
305
322
  : undefined;
306
- const tasks = ctx.listTasks(stateFilter);
323
+ const combined = { ...(stateFilter ?? {}), ...(list ? { list } : {}) };
324
+ const tasks = ctx.listTasks(combined);
307
325
  if (filter === 'blocked') {
308
326
  const blocked = tasks.filter(t => t.blocked);
309
327
  await ctx.reply(renderMarkdownList({
@@ -312,12 +330,63 @@ export const ownerCommands = [
312
330
  }));
313
331
  break;
314
332
  }
333
+ if (grouped) {
334
+ const groups = ctx.groupedTasks(combined);
335
+ await ctx.reply(groups.map(group => renderMarkdownList({
336
+ icon: '📋', title: `Tasks — ${group.list.name}`, empty: 'No tasks found.',
337
+ records: group.tasks.map(taskListRecord),
338
+ })).join('\n\n'));
339
+ break;
340
+ }
315
341
  await ctx.reply(renderMarkdownList({
316
342
  icon: '📋', title: 'Tasks', empty: 'No tasks found.',
317
343
  records: tasks.map(taskListRecord),
318
344
  }));
319
345
  break;
320
346
  }
347
+ case 'lists': {
348
+ const lists = ctx.listTaskLists();
349
+ await ctx.reply(renderMarkdownList({ icon: '📚', title: 'Task lists', empty: 'No task lists found.',
350
+ records: lists.map(list => `${markdownCode(list.name)}${list.built_in ? ' — built-in' : ''}`) }));
351
+ break;
352
+ }
353
+ case 'list-create': {
354
+ const name = trailingLines ?? rest.join(' ');
355
+ if (!name)
356
+ throw new OwnerCommandUsageError('usage: /task list-create\n<name>');
357
+ const list = await ctx.createTaskList(name);
358
+ await ctx.reply(renderMarkdownResult({ icon: '📚', title: 'Task list created', fields: [{ label: 'Name', value: list.name }] }));
359
+ break;
360
+ }
361
+ case 'list-rename': {
362
+ const names = argLines.slice(1).map(line => line.trim()).filter(Boolean);
363
+ const current = names.length === 2 ? names[0] : rest[0];
364
+ const next = names.length === 2 ? names[1] : rest[1];
365
+ if (!current || !next)
366
+ throw new OwnerCommandUsageError('usage: /task list-rename\n<name>\n<new-name>');
367
+ const list = await ctx.renameTaskList(current, next);
368
+ await ctx.reply(renderMarkdownResult({ icon: '📚', title: 'Task list renamed', fields: [{ label: 'Name', value: list.name }] }));
369
+ break;
370
+ }
371
+ case 'list-delete': {
372
+ const names = argLines.slice(1).map(line => line.trim()).filter(Boolean);
373
+ const name = names[0] ?? rest[0];
374
+ const destination = names[1] ?? rest.find(r => r.startsWith('--move-to='))?.slice('--move-to='.length);
375
+ if (!name)
376
+ throw new OwnerCommandUsageError('usage: /task list-delete\n<name>\n[move-to-name]');
377
+ const result = await ctx.deleteTaskList(name, destination);
378
+ await ctx.reply(renderMarkdownResult({ icon: '🗑️', title: 'Task list deleted', fields: [
379
+ { label: 'Name', value: result.deleted.name }, { label: 'Tasks moved', value: String(result.moved) },
380
+ ] }));
381
+ break;
382
+ }
383
+ case 'move': {
384
+ const destination = trailingLines ?? rest.find(r => r.startsWith('--list='))?.slice('--list='.length);
385
+ if (!rest[0] || !destination)
386
+ throw new OwnerCommandUsageError('usage: /task move <id> --list=<name>');
387
+ await showTask((await ctx.moveTask(rest[0], destination)).task_id);
388
+ break;
389
+ }
321
390
  case 'show': {
322
391
  if (!rest[0])
323
392
  throw new OwnerCommandUsageError('usage: /task show <id>');