@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.
- package/README.md +41 -50
- package/dist/application/capabilities.d.ts +0 -1
- package/dist/application/capabilities.js +3 -13
- package/dist/application/errors.d.ts +1 -1
- package/dist/application/fleet-query-service.d.ts +0 -3
- package/dist/application/fleet-query-service.js +1 -24
- package/dist/application/role-creation-service.d.ts +5 -5
- package/dist/application/role-creation-service.js +5 -7
- package/dist/application/role-repository.d.ts +1 -2
- package/dist/application/role-repository.js +7 -7
- package/dist/application/session-control.d.ts +0 -12
- package/dist/application/session-control.js +0 -42
- package/dist/application/session-mutations.d.ts +5 -5
- package/dist/application/task-room-service.d.ts +37 -2
- package/dist/application/task-room-service.js +49 -7
- package/dist/application/types.d.ts +0 -7
- package/dist/atomic-file.d.ts +5 -3
- package/dist/atomic-file.js +32 -8
- package/dist/briefing.js +1 -4
- package/dist/build-info.json +5 -5
- package/dist/cli.js +9 -23
- package/dist/config.d.ts +7 -7
- package/dist/config.js +13 -34
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +46 -30
- package/dist/doctor.js +0 -7
- package/dist/fleet-proxy.d.ts +1 -1
- package/dist/harness/acp-session-transport.d.ts +8 -0
- package/dist/harness/acp-session-transport.js +3 -0
- package/dist/harness/agent-session.d.ts +33 -0
- package/dist/harness/agent-session.js +1 -0
- package/dist/harness/claude-code-session.d.ts +18 -0
- package/dist/harness/claude-code-session.js +28 -0
- package/dist/harness/claude-code.d.ts +2 -1
- package/dist/harness/claude-code.js +29 -81
- package/dist/harness/codex-session.d.ts +18 -0
- package/dist/harness/codex-session.js +28 -0
- package/dist/harness/codex.d.ts +2 -1
- package/dist/harness/codex.js +35 -45
- package/dist/harness/types.d.ts +6 -38
- package/dist/index.d.ts +3 -5
- package/dist/index.js +1 -3
- package/dist/isolation/resources.d.ts +3 -7
- package/dist/isolation/resources.js +3 -7
- package/dist/model-env.d.ts +2 -3
- package/dist/model-env.js +2 -3
- package/dist/monitor.d.ts +1 -24
- package/dist/monitor.js +5 -140
- package/dist/owner-channel/attachments.d.ts +2 -4
- package/dist/owner-channel/attachments.js +6 -39
- package/dist/owner-channel/channel.d.ts +2 -2
- package/dist/owner-channel/channel.js +16 -3
- package/dist/owner-channel/commands.d.ts +18 -1
- package/dist/owner-channel/commands.js +73 -4
- package/dist/rooms-tasks/cli.js +121 -2
- package/dist/rooms-tasks/task-lists.d.ts +19 -0
- package/dist/rooms-tasks/task-lists.js +96 -0
- package/dist/rooms-tasks/task-state.d.ts +3 -0
- package/dist/rooms-tasks/task-state.js +281 -175
- package/dist/rooms-tasks/types.d.ts +11 -1
- package/dist/runner.d.ts +5 -15
- package/dist/runner.js +26 -102
- package/dist/session/acp.d.ts +2 -2
- package/dist/session/acp.js +2 -3
- package/dist/session/activity.d.ts +2 -3
- package/dist/session/activity.js +2 -3
- package/dist/session/arbiter.d.ts +4 -4
- package/dist/session/control.d.ts +3 -3
- package/dist/session/types.d.ts +11 -5
- package/dist/spawn.js +8 -9
- package/dist/supervisor/none.d.ts +1 -1
- package/dist/supervisor/none.js +2 -2
- package/dist/tmux.d.ts +1 -14
- package/dist/tmux.js +1 -51
- package/dist/watchdog/config.js +6 -4
- package/dist/watchdog/run.js +8 -23
- package/dist/web/auth.d.ts +1 -1
- package/dist/web/runtime.js +6 -15
- package/dist/web/server.d.ts +3 -3
- package/dist/web/server.js +89 -15
- package/dist/web/topology-promote.js +2 -5
- package/dist/web-app/assets/index-59GF-gsZ.css +1 -0
- package/dist/web-app/assets/{index-BCBK78hw.js → index-DhMDVRU1.js} +6 -6
- package/dist/web-app/index.html +2 -2
- package/package.json +3 -8
- package/dist/session/tmux.d.ts +0 -28
- package/dist/session/tmux.js +0 -80
- package/dist/web/terminal/bridge.d.ts +0 -27
- package/dist/web/terminal/bridge.js +0 -317
- package/dist/web-app/assets/TerminalView-C_G1ID2P.js +0 -9
- 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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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:
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>');
|