-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathindex.ts
More file actions
1744 lines (1661 loc) · 88.5 KB
/
Copy pathindex.ts
File metadata and controls
1744 lines (1661 loc) · 88.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* pi-dispatch admin extension.
*
* A pi extension that adds a `/dispatch` command for operating a pi-dispatch
* deployment (status, pause, resume, runs, logs, budget, triggers, settings).
*
* Loading: the operator's own pi supplies `ExtensionAPI` at runtime. Three ways
* to load it, all on the operator's host:
* - `pi -e admin/src/index.ts` (explicit, one session)
* - an entry in the `extensions` array of `~/.pi/agent/settings.json`
* - the in-repo `.pi/extensions/dispatch.ts` shim, which pi loads only after
* the operator trusts this checkout (trust gating)
*
* A job container CAN reach this. The job loader runs `noExtensions: false`, so a
* serviced repo's `.pi/extensions` is discovered -- including this repo's own shim
* when pi-dispatch services itself. The runner's recursion guard is what keeps it
* out of the session: it drops admin-like extensions from the loaded set (by entry
* name, and by the `dispatch_*` tools below) and logs the drop.
*
* The extension is a thin channel over the read-model and the renderers: it
* parses the subcommand, calls `read-model.mjs` for data and `render.mjs` for
* text, and picks the output channel. PII-free records go to `sendMessage`
* (they may enter later model context, which is accepted per REQ); raw `.log`
* bytes go ONLY to the overlay viewer, never to a message.
*
* It also registers LLM-callable tools: reads (`dispatch_status`, `dispatch_runs`,
* `dispatch_costs`, `dispatch_triggers`), the durable-but-reversible on/off controls (`dispatch_pause`/
* `dispatch_resume`), the gated PAID enqueue (`dispatch_run`), and the confirm-gated
* writes (`dispatch_set`, `dispatch_trigger_add`/`_edit`/`_delete`) -- each of which
* refuses unless a human operator approves a confirmation dialog showing the concrete
* change. There is still no log tool (raw `.log` bytes never enter model context), and
* a live dashboard overlay renders on the bare `/dispatch` command. The extension also
* ships an `operate-pi-dispatch` skill, advertised via `resources_discover`, that tells
* the model how to use those human-in-the-loop write gates.
*
* `/dispatch setup` (issue #92) runs the guided deployment wizard (setup-wizard.ts); a bare
* `/dispatch` on a host with no deployment at all ENTERS it directly -- the wizard's own first
* select is the consent -- and a one-time session_start nudge names it on a fresh host. The
* wizard sequences the worker CLI's own consented commands and writes the deployment pointer
* this factory applies above. A bare `/dispatch` against a POINTED-AT deployment whose installed
* runtime differs from the pinned one says so once per process, and names setup as the fix.
*
* Tested pi version: 0.80.7 (SUPPORTED_PI_VERSION). The gate is the capability
* probe, not the version: the factory registers nothing unless every API member
* it consumes is present; on a miss it names the member and the tested version
* on stderr and returns. A pi that DIFFERS from the pin but passes the probe
* loads normally -- the mismatch becomes a one-line info advisory drained by the
* next /dispatch, never a refusal (issue #96: a merely newer pi must not scare
* or block anyone).
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
// A VALUE import, deliberately its own line: the type-only line above is regex-anchored by
// pinned-extension-api.test.mjs and must keep its exact shape. VERSION is the HOST's pi version --
// build.mjs keeps pi external, so this resolves against whatever pi actually loaded the extension.
import { VERSION } from "@earendil-works/pi-coding-agent";
import { fileURLToPath, pathToFileURL } from "node:url";
import { Type } from "typebox";
import {
resolvePaths,
readQueueState,
readSchedulers,
readBudget,
listRuns,
readRun,
readLogTail,
readSettingsView,
readTriggers,
readPauseWindows,
listRunIds,
setQueuePaused,
writeSettings,
writeTriggers,
writePauseWindows,
enqueueDispatchRun,
scanRunRecords,
readSubscriptions,
KNOWN_KEYS,
GRAPH_LIMITS,
cronRunStats,
joinRunsToTriggers,
observedChainEdges,
collectGraphInputs,
forgeRepoTargets,
} from "./read-model.mjs";
import { buildGraphModel } from "./graph-model.mjs";
import { buildGraphHtml } from "./graph-html.mjs";
import { openBrowser } from "@edgehero/pi-dispatch/open-browser";
// The deployment pointer (INT-DEPLOYMENT-POINTER-CONTRACT): the wizard-written file that aims this
// extension at a deployment built in another directory. Layered into process.env once at factory load
// (the operator's env always wins), so resolvePaths stays env-only by contract while every one of its
// call sites below is covered without a signature change.
// readPointer/pointerPath join the import for ONE reader: the bare command's version-skew notice, which
// needs the pointed-at `deploymentDir` to find the deployment's installed runtime. readPointer stays pure
// (it never stats that dir) -- the stat lives at the call site below, which is exactly where the pointer
// module's documented purity says it belongs.
import { applyDeploymentPointer, pointerPath, readPointer, takePointerNotice } from "./deployment-pointer.mjs";
// The only fs use in this module: the skew notice reads one package.json through the wizard's own reader.
// Everything else fs-shaped goes through read-model.mjs by design.
import * as nodeFs from "node:fs";
import { COSTS_WINDOWS, costsSinceMs, foldCosts, whatIfFlow } from "./costs.mjs";
// The REAL pricing façade. costs.mjs may not hold a module-scope worker/pricing import by contract (the
// fold is pure; tests inject a canned fake) -- index.ts is where the fs-adjacent assembly lives, so the
// injection happens here.
import { getPricedModel, isZeroRated, listPricedModels, piAiVersion, reprice } from "@edgehero/pi-dispatch/pricing";
import { setGlyphs } from "./panel.mjs";
import { buildSandboxRunArgs, launchSandbox as spawnSandbox, resolveSandbox, sandboxContainerName } from "@edgehero/pi-dispatch/sandbox";
import { readManifest } from "@edgehero/pi-dispatch/sandbox-store";
import { renderStatus, renderRuns, renderBudget, renderTriggers, renderSettingsView, renderCosts, renderWhatIf, renderGraph } from "./render.mjs";
import { makeDashboard, createDashboardDeps } from "./dashboard.ts";
// Only the nudge is loaded eagerly (it must register its session_start handler at factory time); the
// wizard itself stays behind the dispatch handler's lazy import. The setup-wizard module imports
// buildTriggerEntry back from here -- a cycle on paper, but both sides only call across it at runtime.
import { registerNudge } from "./setup-wizard.ts";
import { matchesKey } from "./keys.mjs";
// The single source of truth for the ExtensionAPI surface this extension
// consumes. It grows only when a task actually uses a new member.
export const USED_API = ["registerCommand", "registerTool", "sendMessage", "on"] as const;
export const SUPPORTED_PI_VERSION = "0.80.7";
/**
* The advisory for running on a pi that is not the tested pin (issue #96). Pure over its two inputs
* so the comparison is unit-testable without faking a pi install. Equal versions mean nothing to
* say; pi's own "0.0.0" fallback (config.js: `VERSION = pkg.version || "0.0.0"` when its
* package.json is unreadable) means UNKNOWN, not different -- comparing it would report a bogus
* mismatch on an install the capability probe already vets for real.
*/
export function computePiVersionAdvisory(version: string, supported: string): string | undefined {
if (version === supported || version === "0.0.0") return undefined;
return (
`pi-dispatch admin: running on pi ${version}, tested with pi ${supported} — things should ` +
`work; report anything odd, and check for a newer @edgehero/pi-dispatch-admin`
);
}
// The retained advisory, drained once by the next /dispatch (the takePointerNotice idiom). Computed
// at factory time but NEVER printed there: a successful load is silent, and load.test.mjs pins the
// refusal path to exactly one stderr line. Memoized like deployment-pointer's appliedOnce -- pi may
// in principle evaluate the factory more than once, and a re-evaluation must not re-arm an advisory
// the operator already saw.
let piVersionAdvisory: string | undefined;
let piVersionCheckDone = false;
/**
* The deployment/console skew latch -- piVersionAdvisory's twin, and once per PROCESS for the same
* reason: the two versions cannot change under a running pi, so saying it on every bare `/dispatch`
* would be nagging, not informing. Set only when the notice actually fires, so a session that never
* had skew never burns the latch.
*/
let runtimeSkewNoticed = false;
/** TEST-ONLY: arm the advisory directly (under the pinned devDep pi the natural computation is a no-op). */
export function _setPiVersionAdvisoryForTests(message: string | undefined): void {
piVersionAdvisory = message;
}
const CHANNEL = "pi-dispatch-admin";
const REBUILT_NOTICE = (reason: string) =>
`replaced invalid settings file (${reason}) — other keys were lost`;
const USAGE =
"usage: /dispatch <status|pause|resume|run|runs|logs|budget|costs|graph|triggers|settings|set|unset|setup>";
const KNOWN_SUBCOMMANDS = [
"status",
"pause",
"resume",
"run",
"runs",
"logs",
"budget",
"costs",
"graph",
"triggers",
"settings",
"set",
"unset",
"setup",
] as const;
export default function admin(pi: ExtensionAPI): void {
for (const member of USED_API) {
if (typeof (pi as Record<string, unknown>)[member] !== "function") {
console.error(
`[pi-dispatch/admin] refusing to load: pi is missing '${member}'. ` +
`This extension supports pi ${SUPPORTED_PI_VERSION}.`,
);
return;
}
}
// The probe above is the gate; the version is only an advisory (issue #96). A pi that differs
// from the tested pin yet still carries every consumed member must not scare or block anyone, so
// the mismatch is computed here -- after the probe PASSES, so a refused load stays a one-line
// no-op -- and surfaced by the next /dispatch, never printed at load.
if (!piVersionCheckDone) {
piVersionCheckDone = true;
piVersionAdvisory = computePiVersionAdvisory(VERSION, SUPPORTED_PI_VERSION);
}
// Layer the setup wizard's deployment pointer into process.env exactly once, before anything can call
// resolvePaths(process.env). Placement matters twice over: AFTER the capability probe, so a refused
// load stays a complete no-op (an extension that registers nothing must not mutate the env either);
// and at the FACTORY top rather than inside the /dispatch handler, because resolvePaths runs
// per-command AND per LLM tool call -- an operator who never types /dispatch but lets the model call
// dispatch_status still needs the pointer's paths in place before the first resolve. The try/catch is
// the never-throw doctrine: an extension factory must never fail to load over a bad pointer file; the
// retained notice (surfaced on the next /dispatch) is the error channel, not an exception.
try {
applyDeploymentPointer();
} catch {
// Deliberately swallowed: applyDeploymentPointer degrades internally ({ ignored } + notice), so
// anything reaching here is unexpected -- and still must not take the whole extension down.
}
pi.registerCommand("dispatch", {
description:
"pi-dispatch admin: status|pause|resume|run|runs|logs|budget|costs|triggers|settings|set|unset|setup",
getArgumentCompletions: (prefix) => completeArguments(prefix),
handler: async (args, ctx) => dispatch(pi, args, ctx),
});
registerTools(pi);
registerSkill(pi);
registerNudge(pi);
}
/**
* Advertise the bundled `operate-pi-dispatch` skill to pi via the `resources_discover` event, so it loads
* exactly when this extension does (no separate install step). The skill does not grant capability -- the
* tools do that -- it recommends how to use the human confirm gates on the write tools. pi's resource loader
* honours `skillPaths` from this event; the path is the extension-relative `admin/skills` directory.
*/
function registerSkill(pi: ExtensionAPI): void {
const skillPaths = [fileURLToPath(new URL("../skills", import.meta.url))];
pi.on("resources_discover", () => ({ skillPaths }));
}
/** A one-shot text tool result. Failure is signalled by THROWing from `execute`, never by this shape. */
function toolText(text: string): { content: { type: "text"; text: string }[]; details: Record<string, never> } {
return { content: [{ type: "text", text }], details: {} };
}
/**
* Register the LLM-callable tools. Reads: `dispatch_status`, `dispatch_runs`, `dispatch_costs`,
* `dispatch_triggers`. On/off:
* `dispatch_pause`/`dispatch_resume` (durable, reversible, money-safe -- no confirm). The gated PAID enqueue:
* `dispatch_run`. Confirm-gated writes: `dispatch_set` (a limit/setting) and `dispatch_trigger_add`/`_edit`/
* `_delete`. There is still NO log tool -- raw `.log` bytes never enter model context (DES-ADMIN-VIA-PI-EXTENSION
* injection boundary; REQ acceptance).
*
* The write tools do NOT weaken the money/trigger gates: each routes through `confirmedWrite`, which refuses
* unless a human operator is present (`ctx.hasUI`) and approves a `ctx.ui.confirm` dialog showing the concrete
* before->after. The model emits only the tool CALL; the operator answers the CONFIRM, so a prompt-injected
* session cannot raise the cap or add a paid trigger without a human keypress it cannot forge (the same human
* approval the operator-typed `/dispatch set` and the overlay CRUD already require). Without an interactive UI
* (print/headless) the write is refused, never silently applied.
*
* `dispatch_run` takes no spend-knob params (model/maxTurns/dailyCap/concurrency resolve worker-side) and is
* bounded producer-side by the folder allowlist, the committed per-flow ai-trigger gate read at a pre-agent
* SHA, and a per-hour rate limit; the daily cap stays the worker's. Each read reuses the self-closing
* read-model wrappers: a tool call is a one-shot, so a per-call connection is correct here where a per-tick
* one on the dashboard would not be. A control, write, or enqueue that cannot reach the queue/file THROWs,
* which pi reports to the model as an error rather than a false success.
*/
function registerTools(pi: ExtensionAPI): void {
pi.registerTool({
name: "dispatch_status",
label: "pi-dispatch status",
description:
"Read-only. Reports pi-dispatch queue/worker state: paused flag, job counts, connected workers, today's budget use, schedulers, runtime settings overlay.",
parameters: Type.Object({}),
async execute() {
const paths = resolvePaths(process.env);
const [queue, budget, schedulers] = await Promise.all([
readQueueState({ url: paths.valkeyUrl }),
readBudget({ url: paths.valkeyUrl }),
readSchedulers({ url: paths.valkeyUrl }),
]);
const settings = readSettingsView({ settingsFile: paths.settingsFile });
return toolText(JSON.stringify({ queue, budget, settings, schedulers }));
},
});
pi.registerTool({
name: "dispatch_runs",
label: "pi-dispatch runs",
description:
"Read-only. Returns structured, PII-free run records from the durable run history. Raw job logs are not available to tools — ask the user to run /dispatch logs.",
parameters: Type.Object({
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
jobId: Type.Optional(Type.String()),
}),
async execute(_toolCallId, params) {
const paths = resolvePaths(process.env);
const data = params.jobId
? readRun({ logsDir: paths.logsDir, jobId: params.jobId })
: listRuns({ logsDir: paths.logsDir, limit: params.limit ?? 10 });
return toolText(JSON.stringify(data));
},
});
pi.registerTool({
name: "dispatch_costs",
label: "pi-dispatch costs",
description:
"Read-only. Folds the PII-free run history against the operator's declared subscriptions and pi-ai's " +
"rate tables into the costs read-model: window totals, daily buckets, per-flow and per-model rollups, " +
"per-plan verdicts, and provenance. window = 7d | 30d | mtd (default mtd); flow filters to one flow's runs.",
parameters: Type.Object({ window: Type.Optional(Type.String()), flow: Type.Optional(Type.String()) }),
async execute(_toolCallId, params) {
const window = params.window ?? "mtd";
if (!COSTS_WINDOWS.includes(window)) {
throw new Error(`unknown window '${window}' (7d|30d|mtd)`);
}
const paths = resolvePaths(process.env);
const res = assembleCosts(paths, window, params.flow);
if (res.unreachable) throw new Error(`could not read the run history: ${res.unreachable}`);
// The fold's dollars are TYPED `{ usd, class, floor, ... }` on purpose: the class rides beside every
// number in this JSON, so a model consuming it cannot launder an estimate into a fact by dropping
// the label -- the same discipline fmtCost enforces on the text views.
return toolText(JSON.stringify({ window, fold: res.fold }));
},
});
pi.registerTool({
name: "dispatch_pause",
label: "pi-dispatch pause",
description:
"Durably pauses pi-dispatch job processing: NEW jobs stop starting; running containers finish; jobs still enqueue. Survives worker restart. Reversible via dispatch_resume.",
executionMode: "sequential",
parameters: Type.Object({}),
async execute() {
const paths = resolvePaths(process.env);
const res = await setQueuePaused({ url: paths.valkeyUrl, paused: true });
if (res.unreachable) {
throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`);
}
return toolText("paused");
},
});
pi.registerTool({
name: "dispatch_resume",
label: "pi-dispatch resume",
description: "Re-enables PAID job processing after a pause. Only call when the user explicitly asks to resume.",
executionMode: "sequential",
parameters: Type.Object({}),
async execute() {
const paths = resolvePaths(process.env);
const res = await setQueuePaused({ url: paths.valkeyUrl, paused: false });
if (res.unreachable) {
throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`);
}
return toolText("resumed");
},
});
pi.registerTool({
name: "dispatch_run",
label: "pi-dispatch run",
description:
"Enqueues a PAID pi-dispatch agent run against a local folder, editing it in place with no undo. " +
"Only folders under the operator's PI_DISPATCH_RUN_ROOTS, and only flows whose .pi/skills/<flow>/SKILL.md " +
"(at HEAD) sets ai-trigger: allow, can be started. Refuses a dirty git working tree — no force option. " +
"Rate-limited per hour.",
executionMode: "sequential",
parameters: Type.Object({ folder: Type.String(), flow: Type.String(), task: Type.String() }),
async execute(_toolCallId, params) {
const res = await enqueueDispatchRun({
folder: params.folder,
flow: params.flow,
task: params.task,
aiInvoked: true,
});
if (res.refused) throw new Error(res.refused);
if (res.unreachable) throw new Error(`could not reach the queue: ${res.unreachable}`);
return toolText(JSON.stringify({ jobId: res.jobId, folder: params.folder, flow: params.flow }));
},
});
pi.registerTool({
name: "dispatch_triggers",
label: "pi-dispatch triggers",
description:
"Read-only. Lists the configured triggers as `{ index, ...trigger }` entries. Use the `index` to target " +
"a specific trigger with dispatch_trigger_edit or dispatch_trigger_delete.",
parameters: Type.Object({}),
async execute() {
const paths = resolvePaths(process.env);
const t = readTriggers({ triggersPath: paths.triggersPath });
const data = Array.isArray(t?.triggers)
? t.triggers.map((tr: any, index: number) => ({ index, ...tr }))
: t;
return toolText(JSON.stringify(data));
},
});
pi.registerTool({
name: "dispatch_set",
label: "pi-dispatch set limit",
description:
"Changes a pi-dispatch runtime setting/limit and applies it live. The operator MUST approve a confirm " +
"dialog showing the exact before->after; with no interactive operator the change is refused, never " +
"applied. Omit `value` (or pass empty) to unset a key back to its default. Valid keys: " +
KNOWN_KEYS.join(", ") + ".",
executionMode: "sequential",
parameters: Type.Object({ key: Type.String(), value: Type.Optional(Type.String()) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
if (!KNOWN_KEYS.includes(params.key)) {
throw new Error(`unknown key '${params.key}'. valid keys: ${KNOWN_KEYS.join(", ")}`);
}
const paths = resolvePaths(process.env);
const view = readSettingsView({ settingsFile: paths.settingsFile });
const oldVal = view?.overlay?.[params.key];
const unset = params.value === undefined || params.value.trim() === "";
const newVal = unset ? undefined : coerceSettingValue(params.key, params.value.trim());
const result = await confirmedWrite(
ctx,
{
title: unset ? `Unset ${params.key}` : `Set ${params.key}`,
message: `${params.key}: ${oldVal ?? "(unset)"} -> ${unset ? "(unset)" : newVal}`,
},
() => {
const res = unset
? writeSettings({ settingsFile: paths.settingsFile, mutate: (o) => { delete o[params.key]; return o; } })
: writeSettings({ settingsFile: paths.settingsFile, mutate: (o) => ({ ...o, [params.key]: newVal }) });
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
return { applied: true, key: params.key, value: unset ? null : newVal, rebuiltFrom: res.rebuiltFrom ?? null };
},
);
return toolText(JSON.stringify(result));
},
});
pi.registerTool({
name: "dispatch_trigger_add",
label: "pi-dispatch add trigger",
description:
"Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing " +
"the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs " +
"(its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs " +
"id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to " +
"the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). " +
"label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + " +
"flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which " +
"action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, " +
"gitlab is open|update|reopen|approved. A github review_submitted trigger may also set " +
"reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all " +
"three do. For webhook triggers the repo and the task come from the triggering " +
"issue/PR event — set only the match + flow — and they run under the deployment default model.",
executionMode: "sequential",
parameters: Type.Object({
kind: Type.String(),
flow: Type.String(),
forge: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
pattern: Type.Optional(Type.String()),
folder: Type.Optional(Type.String()),
task: Type.Optional(Type.String()),
phrase: Type.Optional(Type.String()),
labels: Type.Optional(Type.Array(Type.String())),
action: Type.Optional(Type.Array(Type.String())),
model: Type.Optional(Type.String()),
provider: Type.Optional(Type.String()),
maxTurns: Type.Optional(Type.Integer({ minimum: 1 })),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
const entry = buildTriggerEntry(params.kind, params);
if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request)`);
const result = await confirmedWrite(
ctx,
{ title: `Add ${params.kind} trigger`, message: `Add to triggers.json:\n${JSON.stringify(entry)}` },
() => {
const res = writeTriggers({ triggersPath: resolvePaths(process.env).triggersPath, mutate: (list: any[]) => [...list, entry] });
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
return { applied: true, added: entry };
},
);
return toolText(JSON.stringify(result));
},
});
pi.registerTool({
name: "dispatch_trigger_edit",
label: "pi-dispatch edit trigger",
description:
"Changes which flow a trigger runs (by array index from dispatch_triggers) and applies it live. The " +
"operator MUST approve a confirm dialog showing flow before->after; with no interactive operator it is refused.",
executionMode: "sequential",
parameters: Type.Object({ index: Type.Integer({ minimum: 0 }), flow: Type.String() }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const paths = resolvePaths(process.env);
const list = triggerList(paths);
const cur = list[params.index];
if (!cur) throw new Error(`no trigger at index ${params.index} (have ${list.length})`);
const flow = params.flow.trim();
if (!flow) throw new Error("flow must be non-empty");
const result = await confirmedWrite(
ctx,
{ title: `Edit trigger #${params.index + 1}`, message: `trigger #${params.index + 1} (${cur.type}) flow: ${cur.flow ?? "-"} -> ${flow}` },
() => {
const res = writeTriggers({
triggersPath: paths.triggersPath,
mutate: (raw: any[]) => raw.map((tr, i) => (i === params.index ? { ...tr, run: { ...tr.run, flow } } : tr)),
});
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
return { applied: true, index: params.index, flow };
},
);
return toolText(JSON.stringify(result));
},
});
pi.registerTool({
name: "dispatch_pauses",
label: "pi-dispatch pause windows",
description:
"Read-only. Lists the scheduled pause windows (per folder/repo quiet hours) with their array index. " +
"Use the index for dispatch_pause_delete.",
parameters: Type.Object({}),
async execute() {
const paths = resolvePaths(process.env);
const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
const data = Array.isArray(p?.windows) ? p.windows.map((w: any, index: number) => ({ index, ...w })) : p;
return toolText(JSON.stringify(data));
},
});
pi.registerTool({
name: "dispatch_pause_add",
label: "pi-dispatch add pause window",
description:
"Adds a scheduled pause window and applies it live: runs for `scope` (a repo \"owner/name\", a local " +
"folder path, or \"*\" for all) are DEFERRED between `from` and `to` (\"HH:MM\" 24h; from>to = overnight) " +
"and resume automatically after — nothing is dropped, and deferring costs no budget. Optional `tz` (IANA, " +
"default UTC), `days` (mon..sun), `dateFrom`/`dateTo` (\"YYYY-MM-DD\"). The operator MUST approve a confirm " +
"dialog showing the window; refused with no interactive operator.",
executionMode: "sequential",
parameters: Type.Object({
scope: Type.String(),
from: Type.String(),
to: Type.String(),
tz: Type.Optional(Type.String()),
days: Type.Optional(Type.Array(Type.String())),
dateFrom: Type.Optional(Type.String()),
dateTo: Type.Optional(Type.String()),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
const paths = resolvePaths(process.env);
const w = buildPauseWindow(params);
const result = await confirmedWrite(
ctx,
{ title: "Add pause window", message: `Add to pause-windows.json:\n${JSON.stringify(w)}` },
() => {
const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (list: any[]) => [...list, w] });
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
return { applied: true, added: w };
},
);
return toolText(JSON.stringify(result));
},
});
pi.registerTool({
name: "dispatch_pause_delete",
label: "pi-dispatch delete pause window",
description:
"Removes a scheduled pause window (by array index from dispatch_pauses) and applies it live. The operator " +
"MUST approve a confirm dialog showing the window; refused with no interactive operator.",
executionMode: "sequential",
parameters: Type.Object({ index: Type.Integer({ minimum: 0 }) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const paths = resolvePaths(process.env);
const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
const list = Array.isArray(p?.windows) ? p.windows : [];
const cur = list[params.index];
if (!cur) throw new Error(`no pause window at index ${params.index} (have ${list.length})`);
const result = await confirmedWrite(
ctx,
{ title: `Delete pause window #${params.index + 1}`, message: `Remove pause window #${params.index + 1}: ${cur.scope} ${cur.from}-${cur.to} ${cur.tz}` },
() => {
const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (l: any[]) => l.filter((_, i) => i !== params.index) });
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
return { applied: true, deletedIndex: params.index };
},
);
return toolText(JSON.stringify(result));
},
});
pi.registerTool({
name: "dispatch_pause_edit",
label: "pi-dispatch edit pause window",
description:
"Changes fields of an existing pause window (by array index from dispatch_pauses) and applies it live. " +
"Provide only the fields to change (scope/from/to/tz/days/dateFrom/dateTo); the rest keep their current " +
"value. The operator MUST approve a confirm dialog showing the before->after; refused with no interactive " +
"operator.",
executionMode: "sequential",
parameters: Type.Object({
index: Type.Integer({ minimum: 0 }),
scope: Type.Optional(Type.String()),
from: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
tz: Type.Optional(Type.String()),
days: Type.Optional(Type.Array(Type.String())),
dateFrom: Type.Optional(Type.String()),
dateTo: Type.Optional(Type.String()),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
const paths = resolvePaths(process.env);
const p = readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath });
const list = Array.isArray(p?.windows) ? p.windows : [];
const cur = list[params.index];
if (!cur) throw new Error(`no pause window at index ${params.index} (have ${list.length})`);
// A provided field replaces; an omitted one keeps the current value (?? treats undefined as "keep").
// Rebuild through the shared builder so the result is validated the same way as an add.
const before = buildPauseWindow(cur);
const merged = buildPauseWindow({
scope: params.scope ?? cur.scope,
from: params.from ?? cur.from,
to: params.to ?? cur.to,
tz: params.tz ?? cur.tz,
days: params.days ?? cur.days,
dateFrom: params.dateFrom ?? cur.dateFrom,
dateTo: params.dateTo ?? cur.dateTo,
});
const result = await confirmedWrite(
ctx,
{ title: `Edit pause window #${params.index + 1}`, message: `pause window #${params.index + 1}:\n${JSON.stringify(before)}\n→ ${JSON.stringify(merged)}` },
() => {
const res = writePauseWindows({ pauseWindowsPath: paths.pauseWindowsPath, mutate: (l: any[]) => l.map((w, i) => (i === params.index ? merged : w)) });
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
return { applied: true, index: params.index, window: merged };
},
);
return toolText(JSON.stringify(result));
},
});
pi.registerTool({
name: "dispatch_trigger_delete",
label: "pi-dispatch delete trigger",
description:
"Removes a trigger from triggers.json (by array index from dispatch_triggers) and applies it live. The " +
"operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused.",
executionMode: "sequential",
parameters: Type.Object({ index: Type.Integer({ minimum: 0 }) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
const paths = resolvePaths(process.env);
const list = triggerList(paths);
const cur = list[params.index];
if (!cur) throw new Error(`no trigger at index ${params.index} (have ${list.length})`);
const result = await confirmedWrite(
ctx,
{ title: `Delete trigger #${params.index + 1}`, message: `Remove trigger #${params.index + 1}: ${cur.type} -> ${cur.flow ?? "-"}` },
() => {
const res = writeTriggers({ triggersPath: paths.triggersPath, mutate: (raw: any[]) => raw.filter((_, i) => i !== params.index) });
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
return { applied: true, deletedIndex: params.index };
},
);
return toolText(JSON.stringify(result));
},
});
}
/**
* The single human-in-the-loop gate for every write tool. It is what lets a model-callable tool touch the
* cap or the triggers file without breaking CONST-BUDGET-BEFORE-TOKENS / CONST-TRIGGER-AUTHOR-GATE: the model
* emits the CALL, but the mutation runs only after the OPERATOR approves a `ctx.ui.confirm` dialog that shows
* the concrete change. Fail-closed: with no confirm-capable UI (print/headless, `ctx.hasUI` false) it THROWs
* rather than apply -- no operator, no write. An explicit decline is a determinate, non-error outcome
* (`{ applied:false }`) -- the caller must not loop-retry it.
*/
async function confirmedWrite(
ctx: any,
prompt: { title: string; message: string },
doWrite: () => any,
): Promise<any> {
if (!ctx?.hasUI || typeof ctx?.ui?.confirm !== "function") {
throw new Error(
"refused: this change needs an interactive operator to confirm it, and no confirm-capable UI is available (e.g. print/headless mode).",
);
}
const ok = await ctx.ui.confirm(prompt.title, prompt.message);
if (!ok) return { applied: false, reason: "operator declined" };
return doWrite();
}
/** The current triggers as a display list (empty on a missing/invalid file), for index resolution + confirm text. */
function triggerList(paths: any): any[] {
const t = readTriggers({ triggersPath: paths.triggersPath });
return Array.isArray(t?.triggers) ? t.triggers : [];
}
/**
* Build one `{ on, run }` trigger entry from a kind + fields. The single source of truth for the on x run
* matrix (cron -> local, every webhook kind -> a forge): the impossible combination is absent by
* construction, mirroring the worker's load-time matrix. Shared by the add-trigger dialog and the
* `dispatch_trigger_add` tool, so both produce identical shapes. `labels`/`action` accept either an array
* (tool params) or a space-separated string (dialog input) via `asWords`. Returns null for an unknown kind.
*
* `f.forge` selects which forge a webhook trigger listens to, defaulting to github so every existing call
* site -- and every existing entry this rewrites -- is unchanged. It is offered on BOTH paths, unlike
* `run.image`, and the difference is deliberate: an image is a capability the model would gain (choose the
* container and you choose the pi version, the guardrail floor and the loader posture), whereas a forge is
* one it already has -- a model that can add a github trigger can already arm a paid run, and naming
* gitlab instead does not widen that. Both remain gated by the same operator confirm.
*
* An unrecognised forge is passed through rather than corrected, so `parseTriggers` refuses it fail-loud at
* the write. Silently rewriting a typo to github would arm a trigger on a forge the operator did not name.
*
* `run.resume` is deliberately on NEITHER path, following `run.image` rather than `f.forge`, and the test
* is the one that separates those two: is it a capability the model would GAIN? A forge is not — a model
* that can add a github trigger can already arm a paid run, and naming gitlab does not widen that. Resume
* is. Arming it creates a channel in which the agent's own output persists to host disk and is replayed
* into a later job on the same branch, so a model able to set it could arrange for its own reasoning to
* reach a future run. That is a self-influence channel, and it is not one an operator confirm on a single
* dialog meaningfully bounds — the confirm approves one entry, the channel outlives it.
* Enabling it stays an edit to the reviewed `triggers.json` (`docs/sessions.md`), which is the same answer
* `run.image` gets and for a stricter version of the same reason. The panel still DISPLAYS it, because
* reading a disclosure and being able to arm one are different things.
*/
/**
* The forge prompt and the per-forge pull-request action vocabulary, in one place each.
*
* Both were two-way ternaries naming github and gitlab. A third and fourth forge turns a ternary into a
* chain, and an operator offered "github or gitlab" cannot discover that two more exist -- which is a
* different failure from being refused: they simply never try.
*/
const FORGE_PROMPT = "forge — github, gitlab, forgejo or azure";
const PR_ACTION_VOCAB: Record<string, { hint: string; dflt: string }> = {
github: { hint: "labeled opened synchronize reopened review_submitted", dflt: "labeled" },
gitlab: { hint: "open update reopen approved", dflt: "update" },
forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
azure: { hint: "created updated", dflt: "updated" },
};
export function buildTriggerEntry(kind: string, f: any): any {
if (kind === "cron") {
// Optional per-entry provider/model/maxTurns pass through to job.data (highest precedence); omitted when
// blank so the value still resolves against the settings overlay/env at job start (triggers.mjs:127-131).
// undefined keys drop out of the written JSON. Only the local/cron path carries these — github triggers
// run under the global overlay/env model, which the loader enforces.
const run: any = { kind: "local", folder: f.folder, flow: f.flow, task: f.task };
const model = optStr(f.model);
const provider = optStr(f.provider);
const maxTurns = optInt(f.maxTurns);
if (model) run.model = model;
if (provider) run.provider = provider;
if (maxTurns !== undefined) run.maxTurns = maxTurns;
return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
}
const forge = optStr(f.forge) ?? "github";
// `run.repository` is required on an azure label/comment trigger and REFUSED on every other forge's, so
// it is carried only when set and `parseTriggers` decides whether it belongs. Passing it through rather
// than validating here keeps one validator, exactly as an unrecognised forge is passed through to be
// refused fail-loud at the write instead of silently rewritten to github.
const repository = optStr(f.repository);
const forgeRun = (rest: any) => ({ kind: forge, ...rest, ...(repository ? { repository } : {}) });
if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: forgeRun({ flow: f.flow }) };
if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: forgeRun({ flow: f.flow }) };
if (kind === "pull_request") {
const on: any = { type: "pull_request", action: asWords(f.action) };
const any = asWords(f.labels ?? f.any);
if (any.length > 0) on.any = any;
return { on, run: { kind: forge, flow: f.flow } };
}
return null;
}
/**
* Build one pause-window entry from a kind-less field bag (shared by the `dispatch_pause_add` tool and the
* operator dialog). Required scope/from/to; optional tz/days/dateFrom/dateTo included only when non-blank so
* an omitted field drops out of the JSON. `days` accepts an array (tool) or a space-separated string (dialog).
* All value validation (time format, IANA tz, weekday names, date format) lives in the shared
* `parsePauseWindows`, which the write goes through — a bad value is rejected there, never written.
*/
function buildPauseWindow(f: any): any {
const w: any = { scope: String(f.scope ?? "").trim(), from: String(f.from ?? "").trim(), to: String(f.to ?? "").trim() };
const tz = optStr(f.tz);
const days = asWords(f.days);
const dateFrom = optStr(f.dateFrom);
const dateTo = optStr(f.dateTo);
if (tz) w.tz = tz;
if (days.length > 0) w.days = days;
if (dateFrom) w.dateFrom = dateFrom;
if (dateTo) w.dateTo = dateTo;
return w;
}
/** Normalise a labels/action field to a trimmed non-empty string list, accepting an array or a string. */
function asWords(x: any): string[] {
if (Array.isArray(x)) return x.map((s) => String(s).trim()).filter(Boolean);
return splitWords(x);
}
/** A trimmed non-empty string, or undefined (so a blank optional field drops out of the written JSON). */
function optStr(x: any): string | undefined {
const s = String(x ?? "").trim();
return s === "" ? undefined : s;
}
/** A finite number from a string/number, or undefined when blank/absent/non-numeric. */
function optInt(x: any): number | undefined {
if (x === undefined || x === null || String(x).trim() === "") return undefined;
const n = Number(x);
return Number.isFinite(n) ? n : undefined;
}
async function dispatch(pi: ExtensionAPI, args: string, ctx: any): Promise<void> {
const notify = ctx?.ui?.notify?.bind(ctx.ui);
const tokens = args.trim().split(/\s+/).filter(Boolean);
const sub = tokens[0] ?? "";
const paths = resolvePaths(process.env);
// Glyph posture BEFORE any rendering: every /dispatch surface (the overlay, the costs view's sparkline)
// draws through panel.mjs' active table, and this is the one funnel all subcommands pass through. The
// dashboard's own styler opts in per instance (makeStyler's `ascii`, threaded from these same paths in
// makeDashboard), so PI_DISPATCH_ASCII now flips the overlay frame too, not only panel.mjs (issue #54).
setGlyphs(paths.asciiGlyphs);
// Drain the deployment pointer's retained one-line notice (a broken or newer pointer file) into the
// operator's face exactly once -- the REBUILT_NOTICE idiom: a surfaced warning, never a throw, and
// never into model context.
const pnote = takePointerNotice();
if (pnote) notify?.(pnote, "warning");
// Drain the factory's version advisory the same way, once per process -- and at "info", not
// "warning": a different-but-capable pi is a heads-up, not a defect (issue #96). Sits before the
// sub dispatch below, so every subcommand (and the bare command) passes it.
if (piVersionAdvisory) {
notify?.(piVersionAdvisory, "info");
piVersionAdvisory = undefined;
}
if (sub === "") {
// Detection decides what a bare /dispatch means (issue #92). Lazy import: the wizard module loads
// only on the bare command and `setup`, never for the read subcommands or the LLM tools.
const { detectDeployment, runSetupWizard, readInstalledVersion, runtimeDirFor, RUNTIME_VERSION } = await import(
"./setup-wizard.ts"
);
const det = await detectDeployment({ env: process.env, cwd: ctx?.cwd });
if (det.state === "none") {
// Nothing anywhere: the wizard IS what a bare /dispatch means here, so it is entered DIRECTLY.
// There used to be a confirm in front of it ("…set one up now?"); it is gone on purpose. The
// wizard's own step-1 select -- Guided setup / Open the panel anyway / Cancel -- is the consent,
// and it is a better one: it offers the panel as a real third answer instead of dead-ending a
// decline at a usage line. A confirm asking permission to ask was one keypress that bought
// nothing, and nothing is spawned or written before that select answers.
//
// The degrade for a ctx without dialogs is the wizard's own capability gate (it notifies once and
// returns), so there is deliberately NO notify here: two notices for one degrade reads like two
// separate failures. `initialDetection` hands over the verdict just computed -- one keypress must
// not cost two detections, queue probe included.
await runSetupWizard(paths, ctx, notify, { openDashboardFn: openDashboard, initialDetection: det });
return;
}
if (det.state === "pointer" && !runtimeSkewNoticed) {
// Version skew between the deployment and this console (issue #96's other half): the pointer names
// the folder, the folder's `node_modules` names the runtime version it actually runs, and this
// admin pins the version it was reviewed against. Different is worth ONE line -- `/dispatch setup`
// converges it, because its install step is a no-op when the pin already matches.
//
// Absent or unreadable ⇒ SILENCE, deliberately: an operator running the worker straight from a
// clone has no `node_modules/@edgehero/pi-dispatch` to read, has made a deliberate choice, and
// would be scolded for it every session by a notice that guessed instead of knowing.
const ptrRes: any = readPointer({ path: pointerPath(process.env) });
const deploymentDir = ptrRes.pointer?.deploymentDir;
const installed = deploymentDir ? readInstalledVersion(nodeFs, runtimeDirFor(deploymentDir)) : undefined;
if (typeof installed === "string" && installed !== RUNTIME_VERSION) {
runtimeSkewNoticed = true;
notify?.(
`deployment runtime ${installed}, this console pins ${RUNTIME_VERSION} — run /dispatch setup to upgrade`,
"warning",
);
}
}
if (det.state === "cwd" || det.state === "reachable") {
// A deployment that works only from this directory (cwd scaffold) or was merely probed
// (reachable queue, no config wired): open the panel as always, plus ONE hint that a pointer
// would make it work from anywhere. "pointer"/"env" open with no hint -- exactly as today.
notify?.(`using ${det.detail} — /dispatch setup can write a deployment pointer so this works from anywhere`, "info");
}
await openDashboard(paths, ctx, notify);
return;
}
switch (sub) {
case "status": {
const [queue, budget] = await Promise.all([
readQueueState({ url: paths.valkeyUrl }),
readBudget({ url: paths.valkeyUrl }),
]);
const settings = readSettingsView({ settingsFile: paths.settingsFile });
send(pi, `${renderStatus(queue)}\n${renderBudget({ budget, settings })}`);
return;
}
case "runs": {
const limit = tokens[1] ? Number(tokens[1]) : undefined;
send(pi, renderRuns(listRuns({ logsDir: paths.logsDir, limit })));
return;
}
case "budget": {
const budget = await readBudget({ url: paths.valkeyUrl });
const settings = readSettingsView({ settingsFile: paths.settingsFile });
send(pi, renderBudget({ budget, settings }));
return;
}
case "triggers": {
const schedulers = await readSchedulers({ url: paths.valkeyUrl });
const triggers = readTriggers({ triggersPath: paths.triggersPath });
send(pi, renderTriggers({ schedulers, triggers }));
return;
}
case "settings": {
send(pi, renderSettingsView(readSettingsView({ settingsFile: paths.settingsFile })));
return;
}
case "costs": {
costsCommand(pi, paths, tokens, notify);
return;
}
case "graph": {
// `graph html` writes the self-contained artifact and best-effort opens the browser
// (REQ-GRAPH-HTML-EXPORT); bare `graph` stays the plain-text render. The positional sub-verb is
// the `costs whatif` convention, not a flag on the base command.
if (tokens[1] === "html") {
await graphHtmlCommand(paths, tokens, notify);
return;
}
if (tokens.length > 1) {
notify?.(GRAPH_USAGE, "warning");
return;
}
// Operator-typed read, ungated (DES-CLI-SURFACE: typing it is the approval); renders the same
// model the GRAPH view draws, as plain text into the admin channel. Deliberately NOT an
// LLM-callable tool: the folder enumeration spawns git per folder, and the text is a topology
// the operator reads, not a fold a model consumes.
send(pi, renderGraph(await assembleGraph(paths)));
return;
}
case "run": {
const folder = tokens[1];
const flow = tokens[2];
const task = tokens.slice(3).join(" ");
if (!folder) {
notify?.("usage: /dispatch run <folder> <flow> [task...]", "warning");
return;
}
// Operator path (aiInvoked:false): ungated -- typing the command is the approval -- but the dirty
// guard still fires inside enqueueDispatchRun. No spend knobs; provider/model/maxTurns resolve worker-side.
const res = await enqueueDispatchRun({ folder, flow, task, aiInvoked: false });
if (res.refused) {
notify?.(res.refused, "error");
return;
}
if (res.unreachable) {
notify?.(`could not reach the queue: ${res.unreachable}`, "error");
return;
}
notify?.(`queued ${res.jobId} — ${folder} (${flow})`, "info");
return;
}
case "logs":
await showLogs(paths.logsDir, tokens, ctx);
return;
case "pause":
case "resume": {
const paused = sub === "pause";
const res = await setQueuePaused({ url: paths.valkeyUrl, paused });
if (res.unreachable) {
notify?.(`could not reach Valkey at ${paths.valkeyUrl} — is it running? (docker compose up)`, "error");
return;
}
notify?.(
paused
? "paused — worker will stop taking new jobs (jobs still enqueue; durable, survives restart)"
: "resumed",
"info",
);
return;
}
case "set": {
applySet(paths.settingsFile, tokens, notify);
return;
}
case "unset": {
applyUnset(paths.settingsFile, tokens, notify);
return;
}
case "setup": {
// Same lazy import as the bare branch; openDashboard rides in as a dep so the wizard's final
// step (and its "Open the panel anyway" escape) reuse this module's opener without a cycle at
// evaluation time.
const { runSetupWizard } = await import("./setup-wizard.ts");
await runSetupWizard(paths, ctx, notify, { openDashboardFn: openDashboard });