@detergent-software/conductor 1.0.0-dev.2 → 1.0.0-dev.21
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/CHANGELOG.md +170 -0
- package/README.md +361 -11
- package/build/broker-cli.js +125 -0
- package/build/broker-cli.js.map +1 -0
- package/build/broker.js +154 -0
- package/build/broker.js.map +1 -0
- package/build/cli.js +610 -44
- package/build/cli.js.map +1 -1
- package/build/clock.js +17 -0
- package/build/clock.js.map +1 -0
- package/build/conditions.js +19 -13
- package/build/conditions.js.map +1 -1
- package/build/config.js +94 -1
- package/build/config.js.map +1 -1
- package/build/console.js +99 -0
- package/build/console.js.map +1 -0
- package/build/credential.js +52 -0
- package/build/credential.js.map +1 -0
- package/build/daemon.js +153 -2
- package/build/daemon.js.map +1 -1
- package/build/events.js +106 -0
- package/build/events.js.map +1 -0
- package/build/history.js +630 -0
- package/build/history.js.map +1 -0
- package/build/inbox.js +386 -0
- package/build/inbox.js.map +1 -0
- package/build/manager.js +94 -16
- package/build/manager.js.map +1 -1
- package/build/mod.js +51 -0
- package/build/mod.js.map +1 -0
- package/build/note.js +71 -0
- package/build/note.js.map +1 -0
- package/build/notes.js +172 -0
- package/build/notes.js.map +1 -0
- package/build/notify.js +422 -0
- package/build/notify.js.map +1 -0
- package/build/overrides.js +83 -0
- package/build/overrides.js.map +1 -0
- package/build/paths.js +10 -1
- package/build/paths.js.map +1 -1
- package/build/profile.js +68 -2
- package/build/profile.js.map +1 -1
- package/build/pushover.js +67 -0
- package/build/pushover.js.map +1 -0
- package/build/queue.js +183 -0
- package/build/queue.js.map +1 -0
- package/build/registry.js +17 -10
- package/build/registry.js.map +1 -1
- package/build/rule-actions.js +444 -0
- package/build/rule-actions.js.map +1 -0
- package/build/rule-input.js +17 -0
- package/build/rule-input.js.map +1 -0
- package/build/rules-service.js +402 -0
- package/build/rules-service.js.map +1 -0
- package/build/rules.js +850 -0
- package/build/rules.js.map +1 -0
- package/build/scheduler.js +274 -0
- package/build/scheduler.js.map +1 -0
- package/build/server.js +294 -0
- package/build/server.js.map +1 -1
- package/build/spend.js +50 -0
- package/build/spend.js.map +1 -1
- package/build/store.js +452 -4
- package/build/store.js.map +1 -1
- package/build/trackers.js +12 -0
- package/build/trackers.js.map +1 -0
- package/build/upgrade.js +36 -22
- package/build/upgrade.js.map +1 -1
- package/build/wait.js +35 -41
- package/build/wait.js.map +1 -1
- package/build/watchers/ado-api.js +55 -0
- package/build/watchers/ado-api.js.map +1 -0
- package/build/watchers/ado.js +37 -49
- package/build/watchers/ado.js.map +1 -1
- package/build/watchers/boards-queue.js +120 -0
- package/build/watchers/boards-queue.js.map +1 -0
- package/build/watchers/boards-writes.js +83 -0
- package/build/watchers/boards-writes.js.map +1 -0
- package/build/watchers/boards.js +267 -0
- package/build/watchers/boards.js.map +1 -0
- package/build/watchers/github.js +5 -2
- package/build/watchers/github.js.map +1 -1
- package/build/watchers/index.js +1 -0
- package/build/watchers/index.js.map +1 -1
- package/build/watchers/linear-api.js +55 -0
- package/build/watchers/linear-api.js.map +1 -0
- package/build/watchers/linear-key.js +6 -45
- package/build/watchers/linear-key.js.map +1 -1
- package/build/watchers/linear-labels.js +43 -0
- package/build/watchers/linear-labels.js.map +1 -0
- package/build/watchers/linear-queue.js +96 -0
- package/build/watchers/linear-queue.js.map +1 -0
- package/build/watchers/linear.js +29 -18
- package/build/watchers/linear.js.map +1 -1
- package/build/why.js +78 -0
- package/build/why.js.map +1 -0
- package/mod/.claude-plugin/marketplace.json +6 -0
- package/mod/.claude-plugin/plugin.json +7 -0
- package/mod/README.md +17 -0
- package/mod/hooks/hooks.json +1 -0
- package/mod/hooks/model.ts +457 -0
- package/mod/hooks/register.tsx +543 -0
- package/mod/types/index.d.ts +57 -0
- package/package.json +6 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "conductor",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The conductor console's plumbing: a status line in every session, and in the console the inbox band, the fleet pane, zero-token commands, Remote Control pushes and escalation wake-ups.",
|
|
5
|
+
"author": { "name": "Emergent Software" },
|
|
6
|
+
"types": "./types/index.d.ts"
|
|
7
|
+
}
|
package/mod/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Conductor mod
|
|
2
|
+
|
|
3
|
+
A Claude Code plugin (a hooks module) that does the conductor console's plumbing in code, so it costs no tokens: a status line in every session and, in the console (`CONDUCTOR_CONSOLE=1`), the inbox band, the fleet pane, `/conductor` `/inbox` `/approve` `/revise` `/attach` `/notify` `/pause` `/unpause` `/focus-project`, toasts, Remote Control pushes (and the one warning when Remote Control is off), and the one model wake-up for an escalation. What it shows and does is in the conductor README's *Mod* section (`apps/conductor/README.md`).
|
|
4
|
+
|
|
5
|
+
Install it with the daemon's CLI, which ships this folder:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
conduct install-mod
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| File | Holds |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `hooks/register.tsx` | the hooks: session start, commands, the band, the pane, the event stream; only its top-level functions take `$` |
|
|
14
|
+
| `hooks/model.ts` | the logic without the engine: parsing the daemon's answers, what the status line, band and pane show, what each event asks for |
|
|
15
|
+
| `types/index.d.ts` | the `$.state` contract (`conductor.snapshot`, `confirming`, `expanded`) |
|
|
16
|
+
| `tests/conductor.test.tsx` | `claude plugin test`: the hooks against the engine, the daemon faked beneath them |
|
|
17
|
+
| `tests/model.spec.ts` | vitest (`pnpm -C apps/conductor test`, in CI): `model.ts` |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// The conductor mod's logic, with nothing of the engine in it: what the daemon's answers mean, what
|
|
2
|
+
// the status line, band and pane show, and what each event asks the mod to do. register.tsx does
|
|
3
|
+
// the engine's part (only its top-level functions may take `$`); vitest tests this file in CI
|
|
4
|
+
// (apps/conductor/mod/tests/model.spec.ts).
|
|
5
|
+
import type { ConductorInboxItem, ConductorNotify, ConductorSnapshot, ConductorWorker } from '../types';
|
|
6
|
+
|
|
7
|
+
// A daemon API answer: its status and parsed JSON body.
|
|
8
|
+
export type ApiAnswer = { body: unknown; status: number };
|
|
9
|
+
|
|
10
|
+
// One line of `conduct events --follow`: an event of the log (with its id), or the stream's state.
|
|
11
|
+
export type StreamLine = { data: Record<string, unknown>; id: number | null; kind: string; subject: string | null };
|
|
12
|
+
|
|
13
|
+
// What an event asks of the mod. Only `escalate` wakes the console's model.
|
|
14
|
+
export type Reaction =
|
|
15
|
+
| { kind: 'disconnected' }
|
|
16
|
+
| { kind: 'escalate'; text: string }
|
|
17
|
+
| { kind: 'ignore' }
|
|
18
|
+
| { kind: 'push'; message: string }
|
|
19
|
+
| { kind: 'refresh'; toast: string | null }
|
|
20
|
+
| { kind: 'send'; text: string; to: string };
|
|
21
|
+
|
|
22
|
+
// What a Remote Control push came to: reached the phone (ok); not, as Remote Control is off
|
|
23
|
+
// (inactive) or pushes are off in Claude Code's config (disabled); not sent to the phone on
|
|
24
|
+
// purpose (skipped: Jason is at the terminal, or the answer says nothing about the phone); or the
|
|
25
|
+
// call failed (failed: it threw, was denied, or answered an error).
|
|
26
|
+
export type PushOutcome = { detail: string; state: 'disabled' | 'failed' | 'inactive' | 'ok' | 'skipped' };
|
|
27
|
+
|
|
28
|
+
// Said once in the console when pushes stop reaching the phone (ATK-255), by why.
|
|
29
|
+
export function pushWarning(outcome: PushOutcome): null | string {
|
|
30
|
+
switch (outcome.state) {
|
|
31
|
+
case 'disabled':
|
|
32
|
+
return "conductor: mobile pushes are off in Claude Code's config, so pushes reach this terminal only. Turn them on, or /notify away for Pushover.";
|
|
33
|
+
case 'failed':
|
|
34
|
+
return `conductor: a Remote Control push failed (${outcome.detail}). /notify away sends by Pushover instead.`;
|
|
35
|
+
case 'inactive':
|
|
36
|
+
return 'conductor: Remote Control is off, so pushes reach this terminal only. Connect Remote Control, or /notify away for Pushover.';
|
|
37
|
+
case 'ok':
|
|
38
|
+
case 'skipped':
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The state the daemon records for a push's outcome (POST /notify/report).
|
|
44
|
+
export function reportedState(outcome: PushOutcome): 'failing' | 'inactive' | 'ok' | 'skipped' {
|
|
45
|
+
if (outcome.state === 'disabled') return 'inactive';
|
|
46
|
+
return outcome.state === 'failed' ? 'failing' : outcome.state;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A band entry and the act its press does.
|
|
50
|
+
export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
|
|
51
|
+
|
|
52
|
+
export type ProjectGroup = { project: string; rows: ConductorWorker[]; running: number };
|
|
53
|
+
|
|
54
|
+
// A lookup by the item key, or an inbox id written #12; a message when it doesn't resolve to one.
|
|
55
|
+
export type Found<T> = { error: string; found?: undefined } | { error?: undefined; found: T };
|
|
56
|
+
|
|
57
|
+
const BAND_LIMIT = 5;
|
|
58
|
+
|
|
59
|
+
// `conductor 3▶ 2⏸ ✉2`, or undefined (the line cleared) when no daemon answers.
|
|
60
|
+
export function statusText(snapshot: ConductorSnapshot | null): string | undefined {
|
|
61
|
+
if (snapshot === null || snapshot.error !== null) return undefined;
|
|
62
|
+
const running = snapshot.workers.filter((w) => w.status === 'running').length;
|
|
63
|
+
const parked = snapshot.workers.filter((w) => w.status === 'parked').length;
|
|
64
|
+
return `conductor ${running}▶ ${parked}⏸ ✉${snapshot.inbox.length}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The band's buttons: a plan approves (behind a confirm), an npm OTP copies its command, the rest
|
|
68
|
+
// open the pane, where each item's commands and links are.
|
|
69
|
+
export function bandEntries(inbox: readonly ConductorInboxItem[]): BandEntry[] {
|
|
70
|
+
return inbox.slice(0, BAND_LIMIT).map((item, i) => {
|
|
71
|
+
const key = item.item ?? `#${item.id}`;
|
|
72
|
+
const hotkey = String(i + 1);
|
|
73
|
+
if (item.kind === 'plan' && item.item !== null) return { act: 'approve', hotkey, id: item.id, label: `approve ${key}`, text: item.text };
|
|
74
|
+
const command = item.commands[0];
|
|
75
|
+
if (item.kind === 'npm-otp' && command !== undefined) return { act: 'copy', hotkey, id: item.id, label: `copy OTP line ${key}`, text: command };
|
|
76
|
+
return { act: 'pane', hotkey, id: item.id, label: `${item.kind} ${key}`, text: item.text };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The fleet by project, running workers first within each, projects by name.
|
|
81
|
+
export function projectGroups(workers: readonly ConductorWorker[]): ProjectGroup[] {
|
|
82
|
+
const byProject = new Map<string, ConductorWorker[]>();
|
|
83
|
+
for (const w of workers) byProject.set(w.project, [...(byProject.get(w.project) ?? []), w]);
|
|
84
|
+
return [...byProject.entries()]
|
|
85
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
86
|
+
.map(([project, rows]) => ({
|
|
87
|
+
project,
|
|
88
|
+
rows: [...rows].sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') || a.name.localeCompare(b.name)),
|
|
89
|
+
running: rows.filter((w) => w.status === 'running').length,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A worker row's glyph: ▶ running, ⏸ parked, ✖ failed or stopped, · otherwise (launching, exited).
|
|
94
|
+
export function glyph(status: string): string {
|
|
95
|
+
if (status === 'running') return '▶';
|
|
96
|
+
if (status === 'parked') return '⏸';
|
|
97
|
+
if (status === 'failed' || status === 'stopped-unexpectedly') return '✖';
|
|
98
|
+
return '·';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// What a worker waits on or does: its open wait, else the session's activity.
|
|
102
|
+
export function workerDoing(w: ConductorWorker): string {
|
|
103
|
+
return w.waits[0] ?? w.activity;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
|
|
107
|
+
export function findPlan(inbox: readonly ConductorInboxItem[], ref: string): Found<ConductorInboxItem> {
|
|
108
|
+
const byId = /^#(\d+)$/.exec(ref.trim());
|
|
109
|
+
if (byId) {
|
|
110
|
+
const item = inbox.find((i) => i.id === Number(byId[1]));
|
|
111
|
+
return item ? { found: item } : { error: `no open inbox item ${ref}` };
|
|
112
|
+
}
|
|
113
|
+
if (/^\d+$/.test(ref.trim())) return { error: `"${ref}" is ambiguous: name the item, such as ATK-${ref.trim()}, or the inbox id as #${ref.trim()}` };
|
|
114
|
+
const plans = inbox.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.trim().toUpperCase());
|
|
115
|
+
if (plans.length === 1 && plans[0]) return { found: plans[0] };
|
|
116
|
+
if (plans.length === 0) return { error: `no plan of ${ref} waits in the inbox` };
|
|
117
|
+
return { error: `several plans of ${ref} wait: name one as ${plans.map((p) => `#${p.id}`).join(' or ')}` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A worker in flight by name or item; a name match wins, as `conduct attach` finds it.
|
|
121
|
+
export function findWorker(workers: readonly ConductorWorker[], ref: string): Found<ConductorWorker> {
|
|
122
|
+
const byName = workers.filter((w) => w.name === ref);
|
|
123
|
+
const matches = byName.length > 0 ? byName : workers.filter((w) => w.item.toUpperCase() === ref.toUpperCase());
|
|
124
|
+
if (matches.length === 1 && matches[0]) return { found: matches[0] };
|
|
125
|
+
if (matches.length === 0) return { error: `no worker "${ref}" in flight` };
|
|
126
|
+
return { error: `"${ref}" names several workers: ${matches.map((w) => `${w.name} in ${w.project}`).join(', ')}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// `/inbox`'s answer: one line per item, then its commands and links.
|
|
130
|
+
export function inboxText(inbox: readonly ConductorInboxItem[], now: number): string {
|
|
131
|
+
if (inbox.length === 0) return 'The inbox is empty.';
|
|
132
|
+
return inbox
|
|
133
|
+
.flatMap((i) => [
|
|
134
|
+
`#${i.id} ${i.kind} ${i.item ?? ''}${i.project ? ` (${i.project})` : ''} ${age(i.raisedAt, now)}`,
|
|
135
|
+
` ${i.text}`,
|
|
136
|
+
...i.commands.map((c) => ` $ ${c}`),
|
|
137
|
+
...i.links.map((l) => ` ${l}`),
|
|
138
|
+
])
|
|
139
|
+
.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// "now", "12m", "3h", "2d".
|
|
143
|
+
export function age(iso: string, now: number): string {
|
|
144
|
+
const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
|
|
145
|
+
if (!(minutes >= 1)) return 'now';
|
|
146
|
+
if (minutes < 60) return `${minutes}m`;
|
|
147
|
+
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
|
|
148
|
+
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
|
|
152
|
+
export function remaining(iso: string, now: number): string {
|
|
153
|
+
const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
|
|
154
|
+
if (!(minutes >= 1)) return '<1m';
|
|
155
|
+
if (minutes < 60) return `${minutes}m`;
|
|
156
|
+
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
|
|
157
|
+
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 950, 42k, 6.1M.
|
|
161
|
+
export function tokens(n: number): string {
|
|
162
|
+
if (n < 1000) return String(n);
|
|
163
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
164
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function spendToday(workers: readonly ConductorWorker[]): number {
|
|
168
|
+
return workers.reduce((sum, w) => sum + w.spendToday, 0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// One line of the follow stream, or null for anything that isn't an event object.
|
|
172
|
+
export function parseLine(line: string): null | StreamLine {
|
|
173
|
+
let value: unknown;
|
|
174
|
+
try {
|
|
175
|
+
value = JSON.parse(line);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (!isRecord(value) || typeof value.kind !== 'string') return null;
|
|
180
|
+
return {
|
|
181
|
+
data: isRecord(value.data) ? value.data : {},
|
|
182
|
+
id: typeof value.id === 'number' ? value.id : null,
|
|
183
|
+
kind: value.kind,
|
|
184
|
+
subject: typeof value.subject === 'string' ? value.subject : null,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// What an event asks of the mod. Remote Control pushes, escalations and worker messages are
|
|
189
|
+
// contracts the daemon's notifier (ATK-313) and rules (ATK-315) emit; the mod only carries them out.
|
|
190
|
+
export function reaction(line: StreamLine): Reaction {
|
|
191
|
+
const text = (key: string): null | string => {
|
|
192
|
+
const value = line.data[key];
|
|
193
|
+
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
|
194
|
+
};
|
|
195
|
+
switch (line.kind) {
|
|
196
|
+
case 'conduct.connected':
|
|
197
|
+
return { kind: 'refresh', toast: null };
|
|
198
|
+
case 'conduct.disconnected':
|
|
199
|
+
return { kind: 'disconnected' };
|
|
200
|
+
case 'console.escalated': {
|
|
201
|
+
const ask = text('text');
|
|
202
|
+
// The id is what the console reports back on (`conduct escalations done <id>`).
|
|
203
|
+
return ask === null
|
|
204
|
+
? { kind: 'ignore' }
|
|
205
|
+
: { kind: 'escalate', text: `[conductor] escalation${line.id === null ? '' : ` #${line.id}`}${line.subject ? ` on ${line.subject}` : ''}: ${ask}` };
|
|
206
|
+
}
|
|
207
|
+
case 'inbox.raised': {
|
|
208
|
+
const ask = text('text');
|
|
209
|
+
return { kind: 'refresh', toast: ask === null ? null : `conductor: ${line.subject ? `${line.subject} · ` : ''}${ask}` };
|
|
210
|
+
}
|
|
211
|
+
case 'notify.failed':
|
|
212
|
+
case 'notify.routed':
|
|
213
|
+
case 'notify.sent':
|
|
214
|
+
// Nothing the mod draws: only notify.profile and notify.channel change the pane.
|
|
215
|
+
return { kind: 'ignore' };
|
|
216
|
+
case 'notify.requested': {
|
|
217
|
+
const message = text('message');
|
|
218
|
+
return line.data.channel === 'remote-control' && message !== null ? { kind: 'push', message } : { kind: 'ignore' };
|
|
219
|
+
}
|
|
220
|
+
case 'watcher.stats':
|
|
221
|
+
return { kind: 'ignore' };
|
|
222
|
+
case 'worker.message-requested': {
|
|
223
|
+
const to = text('to');
|
|
224
|
+
const message = text('message');
|
|
225
|
+
return to === null || message === null ? { kind: 'ignore' } : { kind: 'send', text: message, to };
|
|
226
|
+
}
|
|
227
|
+
default:
|
|
228
|
+
return { kind: 'refresh', toast: null };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// GET /fleet's body, kept to the fields the mod draws; null when it isn't one.
|
|
233
|
+
export function parseFleet(body: unknown): ConductorWorker[] | null {
|
|
234
|
+
if (!isRecord(body) || !Array.isArray(body.workers)) return null;
|
|
235
|
+
return body.workers.flatMap((w): ConductorWorker[] => {
|
|
236
|
+
if (!isRecord(w) || typeof w.id !== 'number' || typeof w.name !== 'string' || typeof w.status !== 'string') return [];
|
|
237
|
+
const spend = isRecord(w.spend) && typeof w.spend.today === 'number' ? w.spend.today : 0;
|
|
238
|
+
const waits = Array.isArray(w.waits)
|
|
239
|
+
? w.waits.flatMap((x) => (isRecord(x) && typeof x.condition === 'string' && x.resolvedAt === null ? [x.condition] : []))
|
|
240
|
+
: [];
|
|
241
|
+
return [
|
|
242
|
+
{
|
|
243
|
+
activity: typeof w.activity === 'string' ? w.activity : w.status,
|
|
244
|
+
id: w.id,
|
|
245
|
+
item: typeof w.item === 'string' ? w.item : w.name,
|
|
246
|
+
name: w.name,
|
|
247
|
+
phase: typeof w.phase === 'string' ? w.phase : null,
|
|
248
|
+
project: typeof w.project === 'string' ? w.project : '?',
|
|
249
|
+
spendToday: spend,
|
|
250
|
+
status: w.status,
|
|
251
|
+
updatedAt: typeof w.updatedAt === 'string' ? w.updatedAt : '',
|
|
252
|
+
waits,
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// GET /inbox's body; null when it isn't one.
|
|
259
|
+
export function parseInbox(body: unknown): ConductorInboxItem[] | null {
|
|
260
|
+
if (!isRecord(body) || !Array.isArray(body.items)) return null;
|
|
261
|
+
return body.items.flatMap((i): ConductorInboxItem[] => {
|
|
262
|
+
if (!isRecord(i) || typeof i.id !== 'number' || typeof i.kind !== 'string' || typeof i.text !== 'string') return [];
|
|
263
|
+
return [
|
|
264
|
+
{
|
|
265
|
+
commands: strings(i.commands),
|
|
266
|
+
id: i.id,
|
|
267
|
+
item: typeof i.item === 'string' ? i.item : null,
|
|
268
|
+
kind: i.kind,
|
|
269
|
+
links: strings(i.links),
|
|
270
|
+
project: typeof i.project === 'string' ? i.project : null,
|
|
271
|
+
raisedAt: typeof i.raisedAt === 'string' ? i.raisedAt : '',
|
|
272
|
+
text: i.text,
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// The error an API answer carries (`{ error, details }`), as one line.
|
|
279
|
+
export function errorOf(answer: ApiAnswer): string {
|
|
280
|
+
const { body } = answer;
|
|
281
|
+
if (isRecord(body) && typeof body.error === 'string') return [body.error, ...strings(body.details)].join('; ');
|
|
282
|
+
return `the conductor answered ${answer.status}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// POST /inbox/:id/resolve's answer as the line to show, as `conduct approve|revise|done` says it.
|
|
286
|
+
export function answerText(action: 'approve' | 'done' | 'revise', id: number, answer: ApiAnswer): string {
|
|
287
|
+
if (answer.status !== 200) return `not ${action === 'done' ? 'done' : `${action}d`}: ${errorOf(answer)}`;
|
|
288
|
+
const body = isRecord(answer.body) ? answer.body : {};
|
|
289
|
+
const key = isRecord(body.item) && typeof body.item.item === 'string' ? body.item.item : `#${id}`;
|
|
290
|
+
if (action === 'done') return `done: ${key}`;
|
|
291
|
+
const comment = isRecord(body.comment) ? body.comment : {};
|
|
292
|
+
const where = typeof comment.url === 'string' ? comment.url : typeof comment.id === 'string' ? comment.id : 'comment posted';
|
|
293
|
+
return `${action === 'approve' ? 'approved' : 'asked for a revision of'} ${key}: ${where}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The daemon's home as the CLI finds it (apps/conductor/src/paths.ts): CONDUCTOR_HOME, else
|
|
297
|
+
// .conductor under USERPROFILE or HOME.
|
|
298
|
+
export function homeOf(env: { conductorHome?: string; home?: string; userProfile?: string }): null | string {
|
|
299
|
+
if (env.conductorHome) return env.conductorHome;
|
|
300
|
+
const user = env.userProfile || env.home;
|
|
301
|
+
return user ? `${user.replace(/[\\/]+$/, '')}/.conductor` : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// The port and token files' text as an endpoint; null unless both are whole.
|
|
305
|
+
export function endpointOf(portText: string, tokenText: string): { port: number; token: string } | null {
|
|
306
|
+
const port = Number(portText.trim());
|
|
307
|
+
const token = tokenText.trim();
|
|
308
|
+
return Number.isInteger(port) && port > 0 && /^[0-9a-f]{64}$/.test(token) ? { port, token } : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// GET /fleet, GET /inbox and GET /notify as one snapshot; `error` set when the daemon didn't answer
|
|
312
|
+
// the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null.
|
|
313
|
+
export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
|
|
314
|
+
const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
|
|
315
|
+
if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
|
|
316
|
+
const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
|
|
317
|
+
if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
|
|
318
|
+
return { error: null, inbox: items, notify: notify?.status === 200 ? parseNotify(notify.body) : null, readAt, workers };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// GET /notify's body, kept to what the mod draws; null when it isn't one.
|
|
322
|
+
export function parseNotify(body: unknown): ConductorNotify | null {
|
|
323
|
+
if (!isRecord(body) || typeof body.profile !== 'string') return null;
|
|
324
|
+
const channels = isRecord(body.channels) ? body.channels : {};
|
|
325
|
+
const remote = isRecord(channels['remote-control']) ? channels['remote-control'] : {};
|
|
326
|
+
return {
|
|
327
|
+
held: typeof body.held === 'number' ? body.held : 0,
|
|
328
|
+
profile: body.profile,
|
|
329
|
+
profiles: strings(body.profiles),
|
|
330
|
+
profileUntil: typeof body.profileUntil === 'string' ? body.profileUntil : null,
|
|
331
|
+
quietNow: body.quietNow === true,
|
|
332
|
+
remoteControl: typeof remote.state === 'string' ? remote.state : 'unknown',
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// What `$.tool.call` of PushNotification answered: `{ result: { pushSent, disabledReason }, text }`.
|
|
337
|
+
// Only a push that reached the phone is ok; Remote Control off (no_transport, or the text saying
|
|
338
|
+
// so) is inactive; pushes turned off in the config are disabled; the rest say nothing of the phone.
|
|
339
|
+
export function pushOutcome(answer: unknown): PushOutcome {
|
|
340
|
+
const call = isRecord(answer) ? answer : {};
|
|
341
|
+
if (typeof call.deny === 'string') return { detail: `refused: ${call.deny}`, state: 'failed' };
|
|
342
|
+
const result = isRecord(call.result) ? call.result : {};
|
|
343
|
+
const detail = typeof call.text === 'string' && call.text.trim() !== '' ? call.text.trim() : typeof result.message === 'string' ? result.message : '';
|
|
344
|
+
if (call.isError === true) return { detail: detail || 'PushNotification answered an error', state: 'failed' };
|
|
345
|
+
if (result.pushSent === true) return { detail, state: 'ok' };
|
|
346
|
+
if (result.disabledReason === 'config_off') return { detail: "Mobile push not sent (off in Claude Code's config)", state: 'disabled' };
|
|
347
|
+
if (result.disabledReason === 'no_transport' || /remote control inactive/i.test(detail)) {
|
|
348
|
+
return { detail: detail || 'Mobile push not sent (Remote Control inactive)', state: 'inactive' };
|
|
349
|
+
}
|
|
350
|
+
if (result.disabledReason === 'user_present') return { detail: 'Mobile push not sent: you were at the terminal (user_present)', state: 'skipped' };
|
|
351
|
+
return { detail: detail || 'PushNotification said nothing about the phone', state: 'skipped' };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// `/notify <profile> [--until <when>]`: --until is a local time (the next one), a date (local
|
|
355
|
+
// midnight) or a date and time, as `conduct notify profile` reads it.
|
|
356
|
+
export function notifyArgs(args: string, now: number): { error: string } | { name: string; until: null | string } {
|
|
357
|
+
const typed = /^(\S+)(?:\s+--until\s+(\S+))?$/.exec(args.trim());
|
|
358
|
+
if (!typed?.[1]) return { error: 'usage: /notify [<profile> [--until <when>]]' };
|
|
359
|
+
const when = typed[2];
|
|
360
|
+
if (when === undefined) return { name: typed[1], until: null };
|
|
361
|
+
const until = untilOf(when, now);
|
|
362
|
+
return typeof until === 'string' ? { name: typed[1], until } : until;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
366
|
+
|
|
367
|
+
// `--until <when>` as an ISO time: 17:00 (the next one), tomorrow or a day (local midnight), a date
|
|
368
|
+
// (local midnight), or a date and time, as `conduct` reads them.
|
|
369
|
+
export function untilOf(when: string, now: number): string | { error: string } {
|
|
370
|
+
const clock = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when);
|
|
371
|
+
const lower = when.toLowerCase();
|
|
372
|
+
const day = DAYS.findIndex((d) => d === lower || d.slice(0, 3) === lower);
|
|
373
|
+
const at = new Date(now);
|
|
374
|
+
if (clock) {
|
|
375
|
+
at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
|
|
376
|
+
if (at.getTime() <= now) at.setDate(at.getDate() + 1);
|
|
377
|
+
} else if (lower === 'tomorrow' || day !== -1) {
|
|
378
|
+
at.setHours(0, 0, 0, 0);
|
|
379
|
+
at.setDate(at.getDate() + (lower === 'tomorrow' ? 1 : (day - at.getDay() + 7) % 7 || 7));
|
|
380
|
+
} else {
|
|
381
|
+
at.setTime(new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when).getTime());
|
|
382
|
+
}
|
|
383
|
+
if (Number.isNaN(at.getTime())) {
|
|
384
|
+
return { error: `--until "${when}" is not a time: 17:00, tomorrow, a day such as monday, or a date and time such as 2026-10-12T09:00` };
|
|
385
|
+
}
|
|
386
|
+
return at.toISOString();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// `/pause [<project>] [--until <when>]`, `/unpause [<project>]` and `/focus-project <project> [--until
|
|
390
|
+
// <when>]` as POST /overrides' body. A focus with no end lasts until midnight, and puts the
|
|
391
|
+
// project's items first (weight 100).
|
|
392
|
+
export function overrideArgs(command: 'focus' | 'pause' | 'unpause', args: string, now: number): { body: Record<string, unknown> } | { error: string } {
|
|
393
|
+
const typed = /^(?:([a-z][a-z0-9-]*))?(?:\s*--until\s+(\S+))?$/.exec(args.trim());
|
|
394
|
+
const usage = { error: command === 'focus' ? 'usage: /focus-project <project> [--until <when>]' : `usage: /${command} [<project>]${command === 'pause' ? ' [--until <when>]' : ''}` };
|
|
395
|
+
if (!typed) return usage;
|
|
396
|
+
const [, project, when] = typed;
|
|
397
|
+
if (command === 'unpause') return when === undefined ? { body: { action: 'unpause', by: 'conductor mod /unpause', ...(project ? { project } : {}) } } : usage;
|
|
398
|
+
const until = when === undefined ? (command === 'focus' ? untilOf('tomorrow', now) : null) : untilOf(when, now);
|
|
399
|
+
if (until !== null && typeof until !== 'string') return until;
|
|
400
|
+
if (command === 'focus') {
|
|
401
|
+
if (!project) return usage;
|
|
402
|
+
return { body: { action: 'focus', by: 'conductor mod /focus-project', project, until, weight: 100 } };
|
|
403
|
+
}
|
|
404
|
+
return { body: { action: 'pause', by: 'conductor mod /pause', ...(project ? { project } : {}), ...(until === null ? {} : { until }) } };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// POST /overrides' answer as the line to show.
|
|
408
|
+
export function overrideText(command: 'focus' | 'pause' | 'unpause', project: null | string, answer: ApiAnswer, now: number): string {
|
|
409
|
+
if (answer.status === 404) return 'the running conductor has no overrides: restart it on this build (conduct upgrade)';
|
|
410
|
+
if (answer.status !== 201) return `not ${command === 'focus' ? 'focused' : `${command}d`}: ${errorOf(answer)}`;
|
|
411
|
+
const set = isRecord(answer.body) && Array.isArray(answer.body.set) ? answer.body.set.filter(isRecord) : [];
|
|
412
|
+
const until = typeof set[0]?.until === 'string' ? ` for ${remaining(set[0].until, now)}` : '';
|
|
413
|
+
const who = project ?? 'every project';
|
|
414
|
+
if (command === 'pause') return `paused launches in ${who}${until || ' until /unpause'}`;
|
|
415
|
+
if (command === 'unpause') return `launches in ${who} are no longer paused`;
|
|
416
|
+
return `${who} goes first${until}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// GET /why?item=…'s answer: the rule behind the item's newest action, in one line.
|
|
420
|
+
export function whyText(item: string, answer: ApiAnswer): string {
|
|
421
|
+
if (answer.status === 404) return 'the running conductor has no conduct why: restart it on this build (conduct upgrade)';
|
|
422
|
+
if (answer.status !== 200) return `why ${item}: ${errorOf(answer)}`;
|
|
423
|
+
const first = isRecord(answer.body) && Array.isArray(answer.body.items) ? answer.body.items.find(isRecord) : undefined;
|
|
424
|
+
if (!first) return `no action on ${item} was caused by a rule`;
|
|
425
|
+
const rule = isRecord(first.rule) ? first.rule : null;
|
|
426
|
+
const latest = Array.isArray(first.chain) && isRecord(first.chain[0]) ? first.chain[0] : null;
|
|
427
|
+
const what = latest && typeof latest.kind === 'string' ? `${latest.kind}: ` : '';
|
|
428
|
+
if (!rule || typeof rule.ref !== 'string') return `${item}: ${what}no rule`;
|
|
429
|
+
const text = typeof rule.when === 'string' && Array.isArray(rule.do) ? ` (when ${rule.when} → ${strings(rule.do).join('; ')})` : '';
|
|
430
|
+
return `${item}: ${what}${rule.ref}${text}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// `/notify` with no profile: the profile, quiet hours, Remote Control.
|
|
434
|
+
export function notifyText(notify: ConductorNotify | null, now: number): string {
|
|
435
|
+
if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
|
|
436
|
+
const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
|
|
437
|
+
return [
|
|
438
|
+
`notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
|
|
439
|
+
`Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
|
|
440
|
+
].join('\n');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// POST /notify/profile's answer as the line to show.
|
|
444
|
+
export function profileText(name: string, answer: ApiAnswer, now: number): string {
|
|
445
|
+
if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
|
|
446
|
+
const notify = parseNotify(answer.body);
|
|
447
|
+
const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
|
|
448
|
+
return `notify profile: ${notify?.profile ?? name}${until}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
452
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function strings(value: unknown): string[] {
|
|
456
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
|
|
457
|
+
}
|