@detergent-software/conductor 2.1.1 → 2.2.0-dev.2
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 +18 -4
- package/README.md +8 -4
- package/build/code-host.js +11 -0
- package/build/code-host.js.map +1 -1
- package/build/daemon.js +5 -3
- package/build/daemon.js.map +1 -1
- package/build/notes.js +1 -0
- package/build/notes.js.map +1 -1
- package/build/profile.js +4 -0
- package/build/profile.js.map +1 -1
- package/build/queue.js +4 -2
- package/build/queue.js.map +1 -1
- package/build/scheduler.js +85 -27
- package/build/scheduler.js.map +1 -1
- package/build/store.js +7 -3
- package/build/store.js.map +1 -1
- package/mod/README.md +2 -0
- package/mod/hooks/model.ts +369 -218
- package/mod/hooks/register.tsx +321 -314
- package/mod/types/index.d.ts +26 -26
- package/package.json +4 -4
package/mod/hooks/model.ts
CHANGED
|
@@ -7,8 +7,11 @@ import type { ConductorInboxItem, ConductorLink, ConductorNotify, ConductorSnaps
|
|
|
7
7
|
// A daemon API answer: its status and parsed JSON body.
|
|
8
8
|
export type ApiAnswer = { body: unknown; status: number };
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
10
|
+
// What a Remote Control push came to: reached the phone (ok); not, as Remote Control is off
|
|
11
|
+
// (inactive) or pushes are off in Claude Code's config (disabled); not sent to the phone on
|
|
12
|
+
// purpose (skipped: Jason is at the terminal, or the answer says nothing about the phone); or the
|
|
13
|
+
// call failed (failed: it threw, was denied, or answered an error).
|
|
14
|
+
export type PushOutcome = { detail: string; state: 'disabled' | 'failed' | 'inactive' | 'ok' | 'skipped' };
|
|
12
15
|
|
|
13
16
|
// What an event asks of the mod. Only `escalate` wakes the console's model.
|
|
14
17
|
export type Reaction =
|
|
@@ -16,14 +19,11 @@ export type Reaction =
|
|
|
16
19
|
| { kind: 'escalate'; text: string }
|
|
17
20
|
| { kind: 'ignore' }
|
|
18
21
|
| { kind: 'push'; message: string }
|
|
19
|
-
| { kind: 'refresh'; toast:
|
|
22
|
+
| { kind: 'refresh'; toast: null | string }
|
|
20
23
|
| { kind: 'send'; text: string; to: string };
|
|
21
24
|
|
|
22
|
-
//
|
|
23
|
-
|
|
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' };
|
|
25
|
+
// One line of `conduct events --follow`: an event of the log (with its id), or the stream's state.
|
|
26
|
+
export type StreamLine = { data: Record<string, unknown>; id: null | number; kind: string; subject: null | string };
|
|
27
27
|
|
|
28
28
|
// Said once in the console when pushes stop reaching the phone (ATK-255), by why.
|
|
29
29
|
export function pushWarning(outcome: PushOutcome): null | string {
|
|
@@ -64,9 +64,27 @@ export const MAX_WARMS = 12;
|
|
|
64
64
|
// The fork's one user message: its answer is thrown away, so the shorter the better.
|
|
65
65
|
export const WARM_PROMPT = 'Cache keep-alive from the conductor mod: answer with the one word ok.';
|
|
66
66
|
|
|
67
|
+
// A band entry and the act its press does.
|
|
68
|
+
export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
|
|
69
|
+
|
|
70
|
+
export type CacheUsage = { cacheRead: number; cacheWrite: number; input: number; output: number };
|
|
71
|
+
|
|
72
|
+
// A lookup by the item key, or an inbox id written #12; a message when it doesn't resolve to one.
|
|
73
|
+
export type Found<T> = { error: string; found?: undefined } | { error?: undefined; found: T };
|
|
67
74
|
// What the console does at an idle point: ping to keep the cache warm, or start fresh.
|
|
68
75
|
export type IdleAction = { kind: 'clear'; reason: string } | { kind: 'warm' };
|
|
69
76
|
|
|
77
|
+
// What a ping came to: the cache held (warm), it had lapsed anyway so pinging doesn't work here
|
|
78
|
+
// (cold: a 5-minute TTL, a model change), the call failed, or there was nothing to fork.
|
|
79
|
+
export type PingOutcome = { kind: 'cold' | 'failed' | 'warm'; usage: CacheUsage | null } | { kind: 'nothing' };
|
|
80
|
+
|
|
81
|
+
export type ProjectGroup = { project: string; rows: ConductorWorker[]; running: number };
|
|
82
|
+
|
|
83
|
+
// The band's line after the mod cleared an idle console, in place of the no-skill warning.
|
|
84
|
+
export function clearedText(reason: string): string {
|
|
85
|
+
return `fresh console (cleared while idle: ${reason}); /${CONSOLE_SKILL} loads with the next prompt`;
|
|
86
|
+
}
|
|
87
|
+
|
|
70
88
|
// `contextTokens` is the context the last response was answered over (absent before the first);
|
|
71
89
|
// `warms` the pings since the last real turn.
|
|
72
90
|
export function idleAction({ contextTokens, warms }: { contextTokens: number | undefined; warms: number }): IdleAction {
|
|
@@ -76,11 +94,6 @@ export function idleAction({ contextTokens, warms }: { contextTokens: number | u
|
|
|
76
94
|
return { kind: 'warm' };
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
// What a ping came to: the cache held (warm), it had lapsed anyway so pinging doesn't work here
|
|
80
|
-
// (cold: a 5-minute TTL, a model change), the call failed, or there was nothing to fork.
|
|
81
|
-
export type PingOutcome = { kind: 'cold' | 'failed' | 'warm'; usage: CacheUsage | null } | { kind: 'nothing' };
|
|
82
|
-
export type CacheUsage = { cacheRead: number; cacheWrite: number; input: number; output: number };
|
|
83
|
-
|
|
84
97
|
// `$.model.fork`'s result. The cache held when it served at least half the context the console's
|
|
85
98
|
// last response was answered over; without that figure, when it served more than it wrote.
|
|
86
99
|
export function pingOutcome(result: unknown, contextTokens: number | undefined): PingOutcome {
|
|
@@ -92,11 +105,6 @@ export function pingOutcome(result: unknown, contextTokens: number | undefined):
|
|
|
92
105
|
return { kind: held ? 'warm' : 'cold', usage };
|
|
93
106
|
}
|
|
94
107
|
|
|
95
|
-
// The band's line after the mod cleared an idle console, in place of the no-skill warning.
|
|
96
|
-
export function clearedText(reason: string): string {
|
|
97
|
-
return `fresh console (cleared while idle: ${reason}); /${CONSOLE_SKILL} loads with the next prompt`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
108
|
function usageOf(value: unknown): CacheUsage | null {
|
|
101
109
|
if (!isRecord(value)) return null;
|
|
102
110
|
const count = (key: string): number => {
|
|
@@ -111,23 +119,12 @@ function usageOf(value: unknown): CacheUsage | null {
|
|
|
111
119
|
};
|
|
112
120
|
}
|
|
113
121
|
|
|
114
|
-
// A band entry and the act its press does.
|
|
115
|
-
export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
|
|
116
|
-
|
|
117
|
-
export type ProjectGroup = { project: string; rows: ConductorWorker[]; running: number };
|
|
118
|
-
|
|
119
|
-
// A lookup by the item key, or an inbox id written #12; a message when it doesn't resolve to one.
|
|
120
|
-
export type Found<T> = { error: string; found?: undefined } | { error?: undefined; found: T };
|
|
121
|
-
|
|
122
122
|
const BAND_LIMIT = 5;
|
|
123
123
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const parked = snapshot.workers.filter((w) => w.status === 'parked').length;
|
|
129
|
-
return `conductor ${running}▶ ${parked}⏸ ✉${snapshot.inbox.length}`;
|
|
130
|
-
}
|
|
124
|
+
// What a worker is up to, by its status and its session's activity (manager.ts's activityOf):
|
|
125
|
+
// in a turn, up between turns, stuck on a prompt (`waiting on permission`), parked on a wait,
|
|
126
|
+
// failed or gone (exited too), or not up yet (launching, starting).
|
|
127
|
+
export type WorkerState = 'failed' | 'idle' | 'parked' | 'prompt' | 'starting' | 'working';
|
|
131
128
|
|
|
132
129
|
// The band's buttons: a plan approves (behind a confirm), an npm OTP copies its command, the rest
|
|
133
130
|
// open the pane, where each item's commands and links are.
|
|
@@ -159,33 +156,141 @@ export function projectGroups(workers: readonly ConductorWorker[]): ProjectGroup
|
|
|
159
156
|
}));
|
|
160
157
|
}
|
|
161
158
|
|
|
162
|
-
//
|
|
163
|
-
export function
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
168
|
-
}
|
|
159
|
+
// `3 running · 2 parked · inbox 2` beside the pane's title, or undefined when no daemon answers.
|
|
160
|
+
export function statusText(snapshot: ConductorSnapshot | null): string | undefined {
|
|
161
|
+
if (snapshot === null || snapshot.error !== null) return undefined;
|
|
162
|
+
const running = snapshot.workers.filter((w) => w.status === 'running').length;
|
|
163
|
+
const parked = snapshot.workers.filter((w) => w.status === 'parked').length;
|
|
164
|
+
return `${running} running · ${parked} parked · inbox ${snapshot.inbox.length}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function workerState(w: Pick<ConductorWorker, 'activity' | 'status'>): WorkerState {
|
|
168
|
+
if (w.status === 'failed' || w.status === 'stopped-unexpectedly' || w.status === 'exited') return 'failed';
|
|
169
|
+
if (w.status === 'parked') return 'parked';
|
|
170
|
+
if (w.status !== 'running') return 'starting';
|
|
171
|
+
if (w.activity === 'working') return 'working';
|
|
172
|
+
if (w.activity.startsWith('idle')) return 'idle';
|
|
173
|
+
if (w.activity.startsWith('waiting on ')) return 'prompt';
|
|
174
|
+
if (w.activity === 'stopped' || w.activity === 'no session') return 'failed';
|
|
175
|
+
return 'starting';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A row's glyph and its theme color. None has an emoji form, which Windows Terminal draws two cells
|
|
179
|
+
// wide (⏸ ✖ ✉ did, ATK-521); the shapes are East Asian ambiguous width, one cell outside CJK setups.
|
|
180
|
+
export const GLYPHS: Readonly<Record<WorkerState, { char: string; color: string }>> = {
|
|
181
|
+
failed: { char: '×', color: 'error' },
|
|
182
|
+
idle: { char: '◌', color: 'suggestion' },
|
|
183
|
+
parked: { char: '○', color: 'subtle' },
|
|
184
|
+
prompt: { char: '!', color: 'warning' },
|
|
185
|
+
starting: { char: '·', color: 'subtle' },
|
|
186
|
+
working: { char: '●', color: 'success' },
|
|
187
|
+
};
|
|
169
188
|
|
|
170
189
|
// A worker's PR as its row shows it: `#1850`; the expanded row shows the whole ref.
|
|
171
190
|
export function prLabel(pr: null | string): string {
|
|
172
191
|
return pr === null ? '' : `#${pr.slice(pr.lastIndexOf('#') + 1)}`;
|
|
173
192
|
}
|
|
174
193
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
194
|
+
const PR_EVENTS: Readonly<Record<string, string>> = { merged: 'to merge' };
|
|
195
|
+
|
|
196
|
+
// The fleet's column widths: the widest name, phase (at most PHASE_MAX) and PR across every project,
|
|
197
|
+
// so the rows line up under each other wherever they are.
|
|
198
|
+
export type FleetColumns = { key: number; phase: number; pr: number };
|
|
199
|
+
|
|
200
|
+
// One worker's row as the pane draws it, in segments, each with its leading space: together at
|
|
201
|
+
// most the pane's width, so it never wraps. `isWait` says `doing` is a wait (drawn full strength).
|
|
202
|
+
export type FleetRow = {
|
|
203
|
+
age: string;
|
|
204
|
+
color: string;
|
|
205
|
+
doing: string;
|
|
206
|
+
glyph: string;
|
|
207
|
+
isWait: boolean;
|
|
208
|
+
key: string;
|
|
209
|
+
meta: string;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// A wait's condition key (conditions.ts's formatCondition) in words, the telling part first so a
|
|
213
|
+
// truncated row keeps it: `PR #557 to merge (agentic-toolkit-registry)`, `npm 1.2.3 on dev (atk)`.
|
|
214
|
+
// A kind this doesn't know, or a key it can't read, stays as it is.
|
|
215
|
+
export function waitLabel(condition: string, now: number): string {
|
|
216
|
+
const colon = condition.indexOf(':');
|
|
217
|
+
if (colon === -1) return condition;
|
|
218
|
+
const rest = condition.slice(colon + 1);
|
|
219
|
+
const read = (pattern: RegExp): null | string[] => pattern.exec(rest)?.slice(1) ?? null;
|
|
220
|
+
const short = (sha: string): string => sha.slice(0, 7);
|
|
221
|
+
const last = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
|
|
222
|
+
switch (condition.slice(0, colon)) {
|
|
223
|
+
case 'approval':
|
|
224
|
+
return 'plan approval';
|
|
225
|
+
case 'health': {
|
|
226
|
+
const [env = '', field = '', value = ''] = read(/^([^:]+):(commit|version)=(.+)$/) ?? [];
|
|
227
|
+
if (env) return `${env} health at ${field === 'commit' ? short(value) : value}`;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'item': {
|
|
231
|
+
const [item = '', field = '', value = ''] = read(/^[^:]+:([^:]+):(state|label|note)=(.*)$/) ?? [];
|
|
232
|
+
if (item) return `${item} ${field} ${value}`.trimEnd();
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'login': {
|
|
236
|
+
const [provider = ''] = read(/^([^:]+):/) ?? [];
|
|
237
|
+
if (provider) return `login (${provider})`;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
case 'merge': {
|
|
241
|
+
const [sha = '', only] = read(/^(?:ado|github):[^@]+@([0-9a-f]+)(?::for=(.+))?$/i) ?? [];
|
|
242
|
+
if (sha) return `runs of ${short(sha)}${only ? ` (${only})` : ''}`;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'npm': {
|
|
246
|
+
const [pkg = '', version = '', tag = ''] = read(/^(.+)@([^@:]+):([^:]+)$/) ?? [];
|
|
247
|
+
if (pkg) return `npm ${version} on ${tag} (${last(pkg)})`;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case 'pr': {
|
|
251
|
+
const [host = '', repo = '', number = '', on = ''] = read(/^(ado|github):([^#]+)#(\d+):(\w+)$/) ?? [];
|
|
252
|
+
// An ADO PR is in the project's own repo; a GitHub one names its repo.
|
|
253
|
+
if (host) return `PR #${number} ${PR_EVENTS[on] ?? on}${host === 'github' ? ` (${last(repo)})` : ''}`;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case 'run': {
|
|
257
|
+
const [pipeline = '', id = ''] = read(/^(?:ado|github):(.+)#(\d+):done$/) ?? [];
|
|
258
|
+
if (pipeline) return `run #${id} to finish (${last(pipeline).replace(/\.ya?ml$/, '')})`;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
case 'time':
|
|
262
|
+
if (!Number.isNaN(Date.parse(rest))) return `timer, ${remaining(rest, now)} left`;
|
|
263
|
+
break;
|
|
184
264
|
}
|
|
265
|
+
return condition;
|
|
185
266
|
}
|
|
186
267
|
|
|
187
|
-
|
|
188
|
-
|
|
268
|
+
// What a worker waits on, in words (and how many more waits), else the session's activity.
|
|
269
|
+
export function workerDoing(w: ConductorWorker, now: number): string {
|
|
270
|
+
const [first, ...more] = w.waits;
|
|
271
|
+
if (first === undefined) return w.activity;
|
|
272
|
+
return `${waitLabel(first, now)}${more.length > 0 ? ` +${more.length}` : ''}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const PHASE_MAX = 13;
|
|
276
|
+
const DOING_MIN = 12;
|
|
277
|
+
const DOING_SHORT = 6;
|
|
278
|
+
const AGE_WIDTH = 4;
|
|
279
|
+
|
|
280
|
+
// "now", "12m", "3h", "2d".
|
|
281
|
+
export function age(iso: string, now: number): string {
|
|
282
|
+
const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
|
|
283
|
+
if (!(minutes >= 1)) return 'now';
|
|
284
|
+
if (minutes < 60) return `${minutes}m`;
|
|
285
|
+
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
|
|
286
|
+
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The error an API answer carries (`{ error, details }`), as one line.
|
|
290
|
+
export function errorOf(answer: ApiAnswer): string {
|
|
291
|
+
const { body } = answer;
|
|
292
|
+
if (isRecord(body) && typeof body.error === 'string') return [body.error, ...strings(body.details)].join('; ');
|
|
293
|
+
return `the conductor answered ${answer.status}`;
|
|
189
294
|
}
|
|
190
295
|
|
|
191
296
|
// The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
|
|
@@ -214,6 +319,51 @@ export function findWorker(workers: readonly ConductorWorker[], ref: string): Fo
|
|
|
214
319
|
return { error: `"${ref}" names several workers: ${matches.map((w) => `${w.name} in ${w.project}`).join(', ')}` };
|
|
215
320
|
}
|
|
216
321
|
|
|
322
|
+
export function fleetColumns(workers: readonly ConductorWorker[]): FleetColumns {
|
|
323
|
+
const widest = (texts: string[]): number => Math.max(0, ...texts.map((t) => t.length));
|
|
324
|
+
return {
|
|
325
|
+
key: widest(workers.map((w) => w.name)),
|
|
326
|
+
phase: Math.min(PHASE_MAX, widest(workers.map((w) => w.phase ?? ''))),
|
|
327
|
+
pr: widest(workers.map((w) => prLabel(w.pr))),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// The row in `width` cells: glyph, name, phase, PR, what it does or waits on, age. Where they don't
|
|
332
|
+
// all fit, the age goes first, then the doing text, then the PR, then the phase. Which columns show
|
|
333
|
+
// depends only on the fleet's widths, so every row keeps the same ones.
|
|
334
|
+
export function fleetRow(w: ConductorWorker, columns: FleetColumns, width: number, now: number): FleetRow {
|
|
335
|
+
const key = Math.max(1, Math.min(columns.key, width - 2));
|
|
336
|
+
// In the order the row draws them.
|
|
337
|
+
/* eslint-disable perfectionist/sort-maps */
|
|
338
|
+
const shown = new Map<'age' | 'doing' | 'phase' | 'pr', number>([
|
|
339
|
+
['phase', columns.phase],
|
|
340
|
+
['pr', columns.pr],
|
|
341
|
+
['doing', DOING_MIN],
|
|
342
|
+
['age', AGE_WIDTH],
|
|
343
|
+
]);
|
|
344
|
+
/* eslint-enable perfectionist/sort-maps */
|
|
345
|
+
for (const [column, cells] of shown) if (cells === 0 && column !== 'doing') shown.delete(column);
|
|
346
|
+
const need = (): number => 2 + key + [...shown.values()].reduce((sum, cells) => sum + cells + 1, 0);
|
|
347
|
+
for (const column of ['age', 'doing', 'pr', 'phase'] as const) if (need() > width) shown.delete(column);
|
|
348
|
+
// The doing text comes back in what room is left, while that still shows a few words.
|
|
349
|
+
if (!shown.has('doing') && width - need() - 1 >= DOING_SHORT) shown.set('doing', width - need() - 1);
|
|
350
|
+
const cell = (column: 'age' | 'doing' | 'phase' | 'pr', text: string): string => {
|
|
351
|
+
const cells = shown.get(column);
|
|
352
|
+
if (cells === undefined) return '';
|
|
353
|
+
return ` ${column === 'doing' ? fit(text, cells + Math.max(0, width - need())) : fit(text, cells)}`;
|
|
354
|
+
};
|
|
355
|
+
const { char, color } = GLYPHS[workerState(w)];
|
|
356
|
+
return {
|
|
357
|
+
age: shown.has('age') ? ` ${age(w.updatedAt, now).padStart(AGE_WIDTH)}` : '',
|
|
358
|
+
color,
|
|
359
|
+
doing: cell('doing', workerDoing(w, now)),
|
|
360
|
+
glyph: char,
|
|
361
|
+
isWait: w.waits.length > 0,
|
|
362
|
+
key: ` ${fit(w.name, key)}`,
|
|
363
|
+
meta: `${cell('phase', w.phase ?? '')}${cell('pr', prLabel(w.pr))}`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
217
367
|
// `/inbox`'s answer: one line per item, then its commands and links.
|
|
218
368
|
export function inboxText(inbox: readonly ConductorInboxItem[], now: number): string {
|
|
219
369
|
if (inbox.length === 0) return 'The inbox is empty.';
|
|
@@ -227,33 +377,65 @@ export function inboxText(inbox: readonly ConductorInboxItem[], now: number): st
|
|
|
227
377
|
.join('\n');
|
|
228
378
|
}
|
|
229
379
|
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
// The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
|
|
240
|
-
export function remaining(iso: string, now: number): string {
|
|
241
|
-
const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
|
|
242
|
-
if (!(minutes >= 1)) return '<1m';
|
|
243
|
-
if (minutes < 60) return `${minutes}m`;
|
|
244
|
-
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
|
|
245
|
-
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
380
|
+
// A link the terminal's [open] hands to the OS opener (ATK-494): one a Link would make a link of,
|
|
381
|
+
// an https: URL or http://localhost. Anything else, such as a path, stays a Link, drawn as text.
|
|
382
|
+
export function isWebLink(href: string): boolean {
|
|
383
|
+
try {
|
|
384
|
+
const url = new URL(href);
|
|
385
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost');
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
246
389
|
}
|
|
247
390
|
|
|
248
|
-
//
|
|
249
|
-
export function
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
391
|
+
// GET /fleet's body, kept to the fields the mod draws; null when it isn't one.
|
|
392
|
+
export function parseFleet(body: unknown): ConductorWorker[] | null {
|
|
393
|
+
if (!isRecord(body) || !Array.isArray(body.workers)) return null;
|
|
394
|
+
return body.workers.flatMap((w): ConductorWorker[] => {
|
|
395
|
+
if (!isRecord(w) || typeof w.id !== 'number' || typeof w.name !== 'string' || typeof w.status !== 'string')
|
|
396
|
+
return [];
|
|
397
|
+
const spend = isRecord(w.spend) && typeof w.spend.today === 'number' ? w.spend.today : 0;
|
|
398
|
+
const waits = Array.isArray(w.waits)
|
|
399
|
+
? w.waits.flatMap((x) =>
|
|
400
|
+
isRecord(x) && typeof x.condition === 'string' && x.resolvedAt === null ? [x.condition] : [],
|
|
401
|
+
)
|
|
402
|
+
: [];
|
|
403
|
+
return [
|
|
404
|
+
{
|
|
405
|
+
activity: typeof w.activity === 'string' ? w.activity : w.status,
|
|
406
|
+
id: w.id,
|
|
407
|
+
item: typeof w.item === 'string' ? w.item : w.name,
|
|
408
|
+
name: w.name,
|
|
409
|
+
phase: typeof w.phase === 'string' ? w.phase : null,
|
|
410
|
+
pr: typeof w.pr === 'string' ? w.pr : null,
|
|
411
|
+
project: typeof w.project === 'string' ? w.project : '?',
|
|
412
|
+
spendToday: spend,
|
|
413
|
+
status: w.status,
|
|
414
|
+
updatedAt: typeof w.updatedAt === 'string' ? w.updatedAt : '',
|
|
415
|
+
waits,
|
|
416
|
+
},
|
|
417
|
+
];
|
|
418
|
+
});
|
|
253
419
|
}
|
|
254
420
|
|
|
255
|
-
|
|
256
|
-
|
|
421
|
+
// GET /inbox's body; null when it isn't one.
|
|
422
|
+
export function parseInbox(body: unknown): ConductorInboxItem[] | null {
|
|
423
|
+
if (!isRecord(body) || !Array.isArray(body.items)) return null;
|
|
424
|
+
return body.items.flatMap((i): ConductorInboxItem[] => {
|
|
425
|
+
if (!isRecord(i) || typeof i.id !== 'number' || typeof i.kind !== 'string' || typeof i.text !== 'string') return [];
|
|
426
|
+
return [
|
|
427
|
+
{
|
|
428
|
+
commands: strings(i.commands),
|
|
429
|
+
id: i.id,
|
|
430
|
+
item: typeof i.item === 'string' ? i.item : null,
|
|
431
|
+
kind: i.kind,
|
|
432
|
+
links: links(i.links),
|
|
433
|
+
project: typeof i.project === 'string' ? i.project : null,
|
|
434
|
+
raisedAt: typeof i.raisedAt === 'string' ? i.raisedAt : '',
|
|
435
|
+
text: i.text,
|
|
436
|
+
},
|
|
437
|
+
];
|
|
438
|
+
});
|
|
257
439
|
}
|
|
258
440
|
|
|
259
441
|
// One line of the follow stream, or null for anything that isn't an event object.
|
|
@@ -285,6 +467,9 @@ export function reaction(line: StreamLine): Reaction {
|
|
|
285
467
|
return { kind: 'refresh', toast: null };
|
|
286
468
|
case 'conduct.disconnected':
|
|
287
469
|
return { kind: 'disconnected' };
|
|
470
|
+
case 'console.cache':
|
|
471
|
+
case 'watcher.stats':
|
|
472
|
+
return { kind: 'ignore' };
|
|
288
473
|
case 'console.escalated': {
|
|
289
474
|
const ask = text('text');
|
|
290
475
|
// The id is what the console reports back on (`conduct escalations done <id>`).
|
|
@@ -313,9 +498,6 @@ export function reaction(line: StreamLine): Reaction {
|
|
|
313
498
|
? { kind: 'push', message }
|
|
314
499
|
: { kind: 'ignore' };
|
|
315
500
|
}
|
|
316
|
-
case 'console.cache':
|
|
317
|
-
case 'watcher.stats':
|
|
318
|
-
return { kind: 'ignore' };
|
|
319
501
|
case 'worker.message-requested': {
|
|
320
502
|
const to = text('to');
|
|
321
503
|
const message = text('message');
|
|
@@ -326,61 +508,30 @@ export function reaction(line: StreamLine): Reaction {
|
|
|
326
508
|
}
|
|
327
509
|
}
|
|
328
510
|
|
|
329
|
-
//
|
|
330
|
-
export function
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const waits = Array.isArray(w.waits)
|
|
337
|
-
? w.waits.flatMap((x) =>
|
|
338
|
-
isRecord(x) && typeof x.condition === 'string' && x.resolvedAt === null ? [x.condition] : [],
|
|
339
|
-
)
|
|
340
|
-
: [];
|
|
341
|
-
return [
|
|
342
|
-
{
|
|
343
|
-
activity: typeof w.activity === 'string' ? w.activity : w.status,
|
|
344
|
-
id: w.id,
|
|
345
|
-
item: typeof w.item === 'string' ? w.item : w.name,
|
|
346
|
-
name: w.name,
|
|
347
|
-
phase: typeof w.phase === 'string' ? w.phase : null,
|
|
348
|
-
pr: typeof w.pr === 'string' ? w.pr : null,
|
|
349
|
-
project: typeof w.project === 'string' ? w.project : '?',
|
|
350
|
-
spendToday: spend,
|
|
351
|
-
status: w.status,
|
|
352
|
-
updatedAt: typeof w.updatedAt === 'string' ? w.updatedAt : '',
|
|
353
|
-
waits,
|
|
354
|
-
},
|
|
355
|
-
];
|
|
356
|
-
});
|
|
511
|
+
// The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
|
|
512
|
+
export function remaining(iso: string, now: number): string {
|
|
513
|
+
const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
|
|
514
|
+
if (!(minutes >= 1)) return '<1m';
|
|
515
|
+
if (minutes < 60) return `${minutes}m`;
|
|
516
|
+
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
|
|
517
|
+
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
357
518
|
}
|
|
358
519
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (!isRecord(body) || !Array.isArray(body.items)) return null;
|
|
362
|
-
return body.items.flatMap((i): ConductorInboxItem[] => {
|
|
363
|
-
if (!isRecord(i) || typeof i.id !== 'number' || typeof i.kind !== 'string' || typeof i.text !== 'string') return [];
|
|
364
|
-
return [
|
|
365
|
-
{
|
|
366
|
-
commands: strings(i.commands),
|
|
367
|
-
id: i.id,
|
|
368
|
-
item: typeof i.item === 'string' ? i.item : null,
|
|
369
|
-
kind: i.kind,
|
|
370
|
-
links: links(i.links),
|
|
371
|
-
project: typeof i.project === 'string' ? i.project : null,
|
|
372
|
-
raisedAt: typeof i.raisedAt === 'string' ? i.raisedAt : '',
|
|
373
|
-
text: i.text,
|
|
374
|
-
},
|
|
375
|
-
];
|
|
376
|
-
});
|
|
520
|
+
export function spendToday(workers: readonly ConductorWorker[]): number {
|
|
521
|
+
return workers.reduce((sum, w) => sum + w.spendToday, 0);
|
|
377
522
|
}
|
|
378
523
|
|
|
379
|
-
//
|
|
380
|
-
export function
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
return
|
|
524
|
+
// 950, 42k, 6.1M.
|
|
525
|
+
export function tokens(n: number): string {
|
|
526
|
+
if (n < 1000) return String(n);
|
|
527
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
528
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// `text` in exactly `cells` cells: padded, or cut with an ellipsis.
|
|
532
|
+
function fit(text: string, cells: number): string {
|
|
533
|
+
if (text.length <= cells) return text.padEnd(cells);
|
|
534
|
+
return cells <= 1 ? '…'.slice(0, cells) : `${text.slice(0, cells - 1).trimEnd()}…`.padEnd(cells);
|
|
384
535
|
}
|
|
385
536
|
|
|
386
537
|
// What `conduct approve|revise` adds when the daemon can't say whether it posted the comment.
|
|
@@ -401,26 +552,11 @@ export function answerText(action: 'approve' | 'done' | 'revise', id: number, an
|
|
|
401
552
|
return `${action === 'approve' ? 'approved' : 'asked for a revision of'} ${key}: ${where}`;
|
|
402
553
|
}
|
|
403
554
|
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
says: { failed: string; ok: string },
|
|
410
|
-
): string {
|
|
411
|
-
const lines = (text: string): string[] =>
|
|
412
|
-
text
|
|
413
|
-
.split('\n')
|
|
414
|
-
.map((l) => l.trim())
|
|
415
|
-
.filter((l) => l !== '');
|
|
416
|
-
if (run.exitCode === 0) return lines(run.stdout).join('; ') || says.ok;
|
|
417
|
-
const stderr = lines(run.stderr);
|
|
418
|
-
const from = stderr.findIndex((l) => l.startsWith('conduct: '));
|
|
419
|
-
const error = stderr
|
|
420
|
-
.slice(Math.max(from, 0))
|
|
421
|
-
.map((l) => l.replace(/^conduct: /, ''))
|
|
422
|
-
.join('; ');
|
|
423
|
-
return `${says.failed}: ${error || `conduct exited ${run.exitCode}`}`;
|
|
555
|
+
// The port and token files' text as an endpoint; null unless both are whole.
|
|
556
|
+
export function endpointOf(portText: string, tokenText: string): null | { port: number; token: string } {
|
|
557
|
+
const port = Number(portText.trim());
|
|
558
|
+
const token = tokenText.trim();
|
|
559
|
+
return Number.isInteger(port) && port > 0 && /^[0-9a-f]{64}$/.test(token) ? { port, token } : null;
|
|
424
560
|
}
|
|
425
561
|
|
|
426
562
|
// The daemon's home as the CLI finds it (apps/conductor/src/paths.ts): CONDUCTOR_HOME, else
|
|
@@ -431,27 +567,15 @@ export function homeOf(env: { conductorHome?: string; home?: string; userProfile
|
|
|
431
567
|
return user ? `${user.replace(/[\\/]+$/, '')}/.conductor` : null;
|
|
432
568
|
}
|
|
433
569
|
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
|
|
444
|
-
const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
|
|
445
|
-
if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
|
|
446
|
-
const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
|
|
447
|
-
if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
|
|
448
|
-
return {
|
|
449
|
-
error: null,
|
|
450
|
-
inbox: items,
|
|
451
|
-
notify: notify?.status === 200 ? parseNotify(notify.body) : null,
|
|
452
|
-
readAt,
|
|
453
|
-
workers,
|
|
454
|
-
};
|
|
570
|
+
// `/notify <profile> [--until <when>]`: --until is a local time (the next one), a date (local
|
|
571
|
+
// midnight) or a date and time, as `conduct notify profile` reads it.
|
|
572
|
+
export function notifyArgs(args: string, now: number): { error: string } | { name: string; until: null | string } {
|
|
573
|
+
const typed = /^(\S+)(?:\s+--until\s+(\S+))?$/.exec(args.trim());
|
|
574
|
+
if (!typed?.[1]) return { error: 'usage: /notify [<profile> [--until <when>]]' };
|
|
575
|
+
const when = typed[2];
|
|
576
|
+
if (when === undefined) return { name: typed[1], until: null };
|
|
577
|
+
const until = untilOf(when, now);
|
|
578
|
+
return typeof until === 'string' ? { name: typed[1], until } : until;
|
|
455
579
|
}
|
|
456
580
|
|
|
457
581
|
// GET /notify's body, kept to what the mod draws; null when it isn't one.
|
|
@@ -494,41 +618,54 @@ export function pushOutcome(answer: unknown): PushOutcome {
|
|
|
494
618
|
return { detail: detail || 'PushNotification said nothing about the phone', state: 'skipped' };
|
|
495
619
|
}
|
|
496
620
|
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
621
|
+
// A `conduct` run (attach, approve, revise) as the line to show: what it printed when it succeeded
|
|
622
|
+
// (`woke …`, `approved …`), else `failed`, then its error and details without the `conduct: `
|
|
623
|
+
// prefix, and without anything node wrote before it (a warning).
|
|
624
|
+
export function runText(
|
|
625
|
+
run: { exitCode: number; stderr: string; stdout: string },
|
|
626
|
+
says: { failed: string; ok: string },
|
|
627
|
+
): string {
|
|
628
|
+
const lines = (text: string): string[] =>
|
|
629
|
+
text
|
|
630
|
+
.split('\n')
|
|
631
|
+
.map((l) => l.trim())
|
|
632
|
+
.filter((l) => l !== '');
|
|
633
|
+
if (run.exitCode === 0) return lines(run.stdout).join('; ') || says.ok;
|
|
634
|
+
const stderr = lines(run.stderr);
|
|
635
|
+
const from = stderr.findIndex((l) => l.startsWith('conduct: '));
|
|
636
|
+
const error = stderr
|
|
637
|
+
.slice(Math.max(from, 0))
|
|
638
|
+
.map((l) => l.replace(/^conduct: /, ''))
|
|
639
|
+
.join('; ');
|
|
640
|
+
return `${says.failed}: ${error || `conduct exited ${run.exitCode}`}`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// GET /fleet, GET /inbox and GET /notify as one snapshot; `error` set when the daemon didn't answer
|
|
644
|
+
// the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null.
|
|
645
|
+
export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
|
|
646
|
+
const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
|
|
647
|
+
if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
|
|
648
|
+
const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
|
|
649
|
+
if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
|
|
650
|
+
return {
|
|
651
|
+
error: null,
|
|
652
|
+
inbox: items,
|
|
653
|
+
notify: notify?.status === 200 ? parseNotify(notify.body) : null,
|
|
654
|
+
readAt,
|
|
655
|
+
workers,
|
|
656
|
+
};
|
|
506
657
|
}
|
|
507
658
|
|
|
508
659
|
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
509
660
|
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
|
|
519
|
-
if (at.getTime() <= now) at.setDate(at.getDate() + 1);
|
|
520
|
-
} else if (lower === 'tomorrow' || day !== -1) {
|
|
521
|
-
at.setHours(0, 0, 0, 0);
|
|
522
|
-
at.setDate(at.getDate() + (lower === 'tomorrow' ? 1 : (day - at.getDay() + 7) % 7 || 7));
|
|
523
|
-
} else {
|
|
524
|
-
at.setTime(new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when).getTime());
|
|
525
|
-
}
|
|
526
|
-
if (Number.isNaN(at.getTime())) {
|
|
527
|
-
return {
|
|
528
|
-
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`,
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
return at.toISOString();
|
|
661
|
+
// `/notify` with no profile: the profile, quiet hours, Remote Control.
|
|
662
|
+
export function notifyText(notify: ConductorNotify | null, now: number): string {
|
|
663
|
+
if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
|
|
664
|
+
const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
|
|
665
|
+
return [
|
|
666
|
+
`notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
|
|
667
|
+
`Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
|
|
668
|
+
].join('\n');
|
|
532
669
|
}
|
|
533
670
|
|
|
534
671
|
// `/pause [<project>] [--until <when>]`, `/unpause [<project>]` and `/focus-project <project> [--until
|
|
@@ -586,6 +723,38 @@ export function overrideText(
|
|
|
586
723
|
return `${who} goes first${until}`;
|
|
587
724
|
}
|
|
588
725
|
|
|
726
|
+
// POST /notify/profile's answer as the line to show.
|
|
727
|
+
export function profileText(name: string, answer: ApiAnswer, now: number): string {
|
|
728
|
+
if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
|
|
729
|
+
const notify = parseNotify(answer.body);
|
|
730
|
+
const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
|
|
731
|
+
return `notify profile: ${notify?.profile ?? name}${until}`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// `--until <when>` as an ISO time: 17:00 (the next one), tomorrow or a day (local midnight), a date
|
|
735
|
+
// (local midnight), or a date and time, as `conduct` reads them.
|
|
736
|
+
export function untilOf(when: string, now: number): string | { error: string } {
|
|
737
|
+
const clock = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when);
|
|
738
|
+
const lower = when.toLowerCase();
|
|
739
|
+
const day = DAYS.findIndex((d) => d === lower || d.slice(0, 3) === lower);
|
|
740
|
+
const at = new Date(now);
|
|
741
|
+
if (clock) {
|
|
742
|
+
at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
|
|
743
|
+
if (at.getTime() <= now) at.setDate(at.getDate() + 1);
|
|
744
|
+
} else if (lower === 'tomorrow' || day !== -1) {
|
|
745
|
+
at.setHours(0, 0, 0, 0);
|
|
746
|
+
at.setDate(at.getDate() + (lower === 'tomorrow' ? 1 : (day - at.getDay() + 7) % 7 || 7));
|
|
747
|
+
} else {
|
|
748
|
+
at.setTime(new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when).getTime());
|
|
749
|
+
}
|
|
750
|
+
if (Number.isNaN(at.getTime())) {
|
|
751
|
+
return {
|
|
752
|
+
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`,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return at.toISOString();
|
|
756
|
+
}
|
|
757
|
+
|
|
589
758
|
// GET /why?item=…'s answer: the rule behind the item's newest action, in one line.
|
|
590
759
|
export function whyText(item: string, answer: ApiAnswer): string {
|
|
591
760
|
if (answer.status === 404)
|
|
@@ -605,24 +774,6 @@ export function whyText(item: string, answer: ApiAnswer): string {
|
|
|
605
774
|
return `${item}: ${what}${rule.ref}${text}`;
|
|
606
775
|
}
|
|
607
776
|
|
|
608
|
-
// `/notify` with no profile: the profile, quiet hours, Remote Control.
|
|
609
|
-
export function notifyText(notify: ConductorNotify | null, now: number): string {
|
|
610
|
-
if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
|
|
611
|
-
const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
|
|
612
|
-
return [
|
|
613
|
-
`notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
|
|
614
|
-
`Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
|
|
615
|
-
].join('\n');
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// POST /notify/profile's answer as the line to show.
|
|
619
|
-
export function profileText(name: string, answer: ApiAnswer, now: number): string {
|
|
620
|
-
if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
|
|
621
|
-
const notify = parseNotify(answer.body);
|
|
622
|
-
const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
|
|
623
|
-
return `notify profile: ${notify?.profile ?? name}${until}`;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
777
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
627
778
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
628
779
|
}
|