@detergent-software/conductor 2.2.0-dev.1 → 2.2.0-dev.3
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 +23 -0
- package/README.md +9 -5
- 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/follow-ups.js +0 -3
- package/build/follow-ups.js.map +1 -1
- package/build/notes.js +1 -0
- package/build/notes.js.map +1 -1
- package/build/profile.js +11 -3
- package/build/profile.js.map +1 -1
- package/build/queue.js +25 -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/build/watchers/boards-queue.js +43 -2
- package/build/watchers/boards-queue.js.map +1 -1
- package/build/watchers/boards-writes.js +6 -2
- package/build/watchers/boards-writes.js.map +1 -1
- package/build/watchers/boards.js +15 -6
- package/build/watchers/boards.js.map +1 -1
- package/build/watchers/issues-queue.js +47 -5
- package/build/watchers/issues-queue.js.map +1 -1
- package/build/watchers/issues-writes.js +21 -2
- package/build/watchers/issues-writes.js.map +1 -1
- package/build/watchers/issues.js +6 -5
- package/build/watchers/issues.js.map +1 -1
- package/build/watchers/linear-queue.js +6 -20
- package/build/watchers/linear-queue.js.map +1 -1
- package/build/watchers/release-wait.js +30 -0
- package/build/watchers/release-wait.js.map +1 -0
- package/mod/README.md +2 -0
- package/mod/hooks/model.ts +284 -281
- package/mod/hooks/register.tsx +266 -265
- 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 `${running} running · ${parked} parked · inbox ${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,10 +156,13 @@ export function projectGroups(workers: readonly ConductorWorker[]): ProjectGroup
|
|
|
159
156
|
}));
|
|
160
157
|
}
|
|
161
158
|
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
166
|
|
|
167
167
|
export function workerState(w: Pick<ConductorWorker, 'activity' | 'status'>): WorkerState {
|
|
168
168
|
if (w.status === 'failed' || w.status === 'stopped-unexpectedly' || w.status === 'exited') return 'failed';
|
|
@@ -193,6 +193,22 @@ export function prLabel(pr: null | string): string {
|
|
|
193
193
|
|
|
194
194
|
const PR_EVENTS: Readonly<Record<string, string>> = { merged: 'to merge' };
|
|
195
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
|
+
|
|
196
212
|
// A wait's condition key (conditions.ts's formatCondition) in words, the telling part first so a
|
|
197
213
|
// truncated row keeps it: `PR #557 to merge (agentic-toolkit-registry)`, `npm 1.2.3 on dev (atk)`.
|
|
198
214
|
// A kind this doesn't know, or a key it can't read, stays as it is.
|
|
@@ -200,7 +216,7 @@ export function waitLabel(condition: string, now: number): string {
|
|
|
200
216
|
const colon = condition.indexOf(':');
|
|
201
217
|
if (colon === -1) return condition;
|
|
202
218
|
const rest = condition.slice(colon + 1);
|
|
203
|
-
const read = (pattern: RegExp): string[]
|
|
219
|
+
const read = (pattern: RegExp): null | string[] => pattern.exec(rest)?.slice(1) ?? null;
|
|
204
220
|
const short = (sha: string): string => sha.slice(0, 7);
|
|
205
221
|
const last = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
|
|
206
222
|
switch (condition.slice(0, colon)) {
|
|
@@ -256,27 +272,53 @@ export function workerDoing(w: ConductorWorker, now: number): string {
|
|
|
256
272
|
return `${waitLabel(first, now)}${more.length > 0 ? ` +${more.length}` : ''}`;
|
|
257
273
|
}
|
|
258
274
|
|
|
259
|
-
// The fleet's column widths: the widest name, phase (at most PHASE_MAX) and PR across every project,
|
|
260
|
-
// so the rows line up under each other wherever they are.
|
|
261
|
-
export type FleetColumns = { key: number; phase: number; pr: number };
|
|
262
|
-
|
|
263
|
-
// One worker's row as the pane draws it, in segments, each with its leading space: together at
|
|
264
|
-
// most the pane's width, so it never wraps. `isWait` says `doing` is a wait (drawn full strength).
|
|
265
|
-
export type FleetRow = {
|
|
266
|
-
age: string;
|
|
267
|
-
color: string;
|
|
268
|
-
doing: string;
|
|
269
|
-
glyph: string;
|
|
270
|
-
isWait: boolean;
|
|
271
|
-
key: string;
|
|
272
|
-
meta: string;
|
|
273
|
-
};
|
|
274
|
-
|
|
275
275
|
const PHASE_MAX = 13;
|
|
276
276
|
const DOING_MIN = 12;
|
|
277
277
|
const DOING_SHORT = 6;
|
|
278
278
|
const AGE_WIDTH = 4;
|
|
279
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}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
|
|
297
|
+
export function findPlan(inbox: readonly ConductorInboxItem[], ref: string): Found<ConductorInboxItem> {
|
|
298
|
+
const byId = /^#(\d+)$/.exec(ref.trim());
|
|
299
|
+
if (byId) {
|
|
300
|
+
const item = inbox.find((i) => i.id === Number(byId[1]));
|
|
301
|
+
return item ? { found: item } : { error: `no open inbox item ${ref}` };
|
|
302
|
+
}
|
|
303
|
+
if (/^\d+$/.test(ref.trim()))
|
|
304
|
+
return {
|
|
305
|
+
error: `"${ref}" is ambiguous: name the item, such as ATK-${ref.trim()}, or the inbox id as #${ref.trim()}`,
|
|
306
|
+
};
|
|
307
|
+
const plans = inbox.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.trim().toUpperCase());
|
|
308
|
+
if (plans.length === 1 && plans[0]) return { found: plans[0] };
|
|
309
|
+
if (plans.length === 0) return { error: `no plan of ${ref} waits in the inbox` };
|
|
310
|
+
return { error: `several plans of ${ref} wait: name one as ${plans.map((p) => `#${p.id}`).join(' or ')}` };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// A worker in flight by name or item; a name match wins, as `conduct attach` finds it.
|
|
314
|
+
export function findWorker(workers: readonly ConductorWorker[], ref: string): Found<ConductorWorker> {
|
|
315
|
+
const byName = workers.filter((w) => w.name === ref);
|
|
316
|
+
const matches = byName.length > 0 ? byName : workers.filter((w) => w.item.toUpperCase() === ref.toUpperCase());
|
|
317
|
+
if (matches.length === 1 && matches[0]) return { found: matches[0] };
|
|
318
|
+
if (matches.length === 0) return { error: `no worker "${ref}" in flight` };
|
|
319
|
+
return { error: `"${ref}" names several workers: ${matches.map((w) => `${w.name} in ${w.project}`).join(', ')}` };
|
|
320
|
+
}
|
|
321
|
+
|
|
280
322
|
export function fleetColumns(workers: readonly ConductorWorker[]): FleetColumns {
|
|
281
323
|
const widest = (texts: string[]): number => Math.max(0, ...texts.map((t) => t.length));
|
|
282
324
|
return {
|
|
@@ -291,12 +333,15 @@ export function fleetColumns(workers: readonly ConductorWorker[]): FleetColumns
|
|
|
291
333
|
// depends only on the fleet's widths, so every row keeps the same ones.
|
|
292
334
|
export function fleetRow(w: ConductorWorker, columns: FleetColumns, width: number, now: number): FleetRow {
|
|
293
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 */
|
|
294
338
|
const shown = new Map<'age' | 'doing' | 'phase' | 'pr', number>([
|
|
295
339
|
['phase', columns.phase],
|
|
296
340
|
['pr', columns.pr],
|
|
297
341
|
['doing', DOING_MIN],
|
|
298
342
|
['age', AGE_WIDTH],
|
|
299
343
|
]);
|
|
344
|
+
/* eslint-enable perfectionist/sort-maps */
|
|
300
345
|
for (const [column, cells] of shown) if (cells === 0 && column !== 'doing') shown.delete(column);
|
|
301
346
|
const need = (): number => 2 + key + [...shown.values()].reduce((sum, cells) => sum + cells + 1, 0);
|
|
302
347
|
for (const column of ['age', 'doing', 'pr', 'phase'] as const) if (need() > width) shown.delete(column);
|
|
@@ -319,10 +364,17 @@ export function fleetRow(w: ConductorWorker, columns: FleetColumns, width: numbe
|
|
|
319
364
|
};
|
|
320
365
|
}
|
|
321
366
|
|
|
322
|
-
// `
|
|
323
|
-
function
|
|
324
|
-
if (
|
|
325
|
-
return
|
|
367
|
+
// `/inbox`'s answer: one line per item, then its commands and links.
|
|
368
|
+
export function inboxText(inbox: readonly ConductorInboxItem[], now: number): string {
|
|
369
|
+
if (inbox.length === 0) return 'The inbox is empty.';
|
|
370
|
+
return inbox
|
|
371
|
+
.flatMap((i) => [
|
|
372
|
+
`#${i.id} ${i.kind} ${i.item ?? ''}${i.project ? ` (${i.project})` : ''} ${age(i.raisedAt, now)}`,
|
|
373
|
+
` ${i.text}`,
|
|
374
|
+
...i.commands.map((c) => ` $ ${c}`),
|
|
375
|
+
...i.links.map((l) => ` ${l.label}: ${l.href}`),
|
|
376
|
+
])
|
|
377
|
+
.join('\n');
|
|
326
378
|
}
|
|
327
379
|
|
|
328
380
|
// A link the terminal's [open] hands to the OS opener (ATK-494): one a Link would make a link of,
|
|
@@ -336,89 +388,71 @@ export function isWebLink(href: string): boolean {
|
|
|
336
388
|
}
|
|
337
389
|
}
|
|
338
390
|
|
|
339
|
-
//
|
|
340
|
-
export function
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
+
});
|
|
354
419
|
}
|
|
355
420
|
|
|
356
|
-
//
|
|
357
|
-
export function
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
+
});
|
|
363
439
|
}
|
|
364
440
|
|
|
365
|
-
//
|
|
366
|
-
export function
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
|
|
381
|
-
if (!(minutes >= 1)) return 'now';
|
|
382
|
-
if (minutes < 60) return `${minutes}m`;
|
|
383
|
-
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
|
|
384
|
-
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
|
|
388
|
-
export function remaining(iso: string, now: number): string {
|
|
389
|
-
const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
|
|
390
|
-
if (!(minutes >= 1)) return '<1m';
|
|
391
|
-
if (minutes < 60) return `${minutes}m`;
|
|
392
|
-
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
|
|
393
|
-
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// 950, 42k, 6.1M.
|
|
397
|
-
export function tokens(n: number): string {
|
|
398
|
-
if (n < 1000) return String(n);
|
|
399
|
-
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
400
|
-
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
export function spendToday(workers: readonly ConductorWorker[]): number {
|
|
404
|
-
return workers.reduce((sum, w) => sum + w.spendToday, 0);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// One line of the follow stream, or null for anything that isn't an event object.
|
|
408
|
-
export function parseLine(line: string): null | StreamLine {
|
|
409
|
-
let value: unknown;
|
|
410
|
-
try {
|
|
411
|
-
value = JSON.parse(line);
|
|
412
|
-
} catch {
|
|
413
|
-
return null;
|
|
414
|
-
}
|
|
415
|
-
if (!isRecord(value) || typeof value.kind !== 'string') return null;
|
|
416
|
-
return {
|
|
417
|
-
data: isRecord(value.data) ? value.data : {},
|
|
418
|
-
id: typeof value.id === 'number' ? value.id : null,
|
|
419
|
-
kind: value.kind,
|
|
420
|
-
subject: typeof value.subject === 'string' ? value.subject : null,
|
|
421
|
-
};
|
|
441
|
+
// One line of the follow stream, or null for anything that isn't an event object.
|
|
442
|
+
export function parseLine(line: string): null | StreamLine {
|
|
443
|
+
let value: unknown;
|
|
444
|
+
try {
|
|
445
|
+
value = JSON.parse(line);
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
if (!isRecord(value) || typeof value.kind !== 'string') return null;
|
|
450
|
+
return {
|
|
451
|
+
data: isRecord(value.data) ? value.data : {},
|
|
452
|
+
id: typeof value.id === 'number' ? value.id : null,
|
|
453
|
+
kind: value.kind,
|
|
454
|
+
subject: typeof value.subject === 'string' ? value.subject : null,
|
|
455
|
+
};
|
|
422
456
|
}
|
|
423
457
|
|
|
424
458
|
// What an event asks of the mod. Remote Control pushes, escalations and worker messages are
|
|
@@ -433,6 +467,9 @@ export function reaction(line: StreamLine): Reaction {
|
|
|
433
467
|
return { kind: 'refresh', toast: null };
|
|
434
468
|
case 'conduct.disconnected':
|
|
435
469
|
return { kind: 'disconnected' };
|
|
470
|
+
case 'console.cache':
|
|
471
|
+
case 'watcher.stats':
|
|
472
|
+
return { kind: 'ignore' };
|
|
436
473
|
case 'console.escalated': {
|
|
437
474
|
const ask = text('text');
|
|
438
475
|
// The id is what the console reports back on (`conduct escalations done <id>`).
|
|
@@ -461,9 +498,6 @@ export function reaction(line: StreamLine): Reaction {
|
|
|
461
498
|
? { kind: 'push', message }
|
|
462
499
|
: { kind: 'ignore' };
|
|
463
500
|
}
|
|
464
|
-
case 'console.cache':
|
|
465
|
-
case 'watcher.stats':
|
|
466
|
-
return { kind: 'ignore' };
|
|
467
501
|
case 'worker.message-requested': {
|
|
468
502
|
const to = text('to');
|
|
469
503
|
const message = text('message');
|
|
@@ -474,61 +508,30 @@ export function reaction(line: StreamLine): Reaction {
|
|
|
474
508
|
}
|
|
475
509
|
}
|
|
476
510
|
|
|
477
|
-
//
|
|
478
|
-
export function
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const waits = Array.isArray(w.waits)
|
|
485
|
-
? w.waits.flatMap((x) =>
|
|
486
|
-
isRecord(x) && typeof x.condition === 'string' && x.resolvedAt === null ? [x.condition] : [],
|
|
487
|
-
)
|
|
488
|
-
: [];
|
|
489
|
-
return [
|
|
490
|
-
{
|
|
491
|
-
activity: typeof w.activity === 'string' ? w.activity : w.status,
|
|
492
|
-
id: w.id,
|
|
493
|
-
item: typeof w.item === 'string' ? w.item : w.name,
|
|
494
|
-
name: w.name,
|
|
495
|
-
phase: typeof w.phase === 'string' ? w.phase : null,
|
|
496
|
-
pr: typeof w.pr === 'string' ? w.pr : null,
|
|
497
|
-
project: typeof w.project === 'string' ? w.project : '?',
|
|
498
|
-
spendToday: spend,
|
|
499
|
-
status: w.status,
|
|
500
|
-
updatedAt: typeof w.updatedAt === 'string' ? w.updatedAt : '',
|
|
501
|
-
waits,
|
|
502
|
-
},
|
|
503
|
-
];
|
|
504
|
-
});
|
|
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`;
|
|
505
518
|
}
|
|
506
519
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
if (!isRecord(body) || !Array.isArray(body.items)) return null;
|
|
510
|
-
return body.items.flatMap((i): ConductorInboxItem[] => {
|
|
511
|
-
if (!isRecord(i) || typeof i.id !== 'number' || typeof i.kind !== 'string' || typeof i.text !== 'string') return [];
|
|
512
|
-
return [
|
|
513
|
-
{
|
|
514
|
-
commands: strings(i.commands),
|
|
515
|
-
id: i.id,
|
|
516
|
-
item: typeof i.item === 'string' ? i.item : null,
|
|
517
|
-
kind: i.kind,
|
|
518
|
-
links: links(i.links),
|
|
519
|
-
project: typeof i.project === 'string' ? i.project : null,
|
|
520
|
-
raisedAt: typeof i.raisedAt === 'string' ? i.raisedAt : '',
|
|
521
|
-
text: i.text,
|
|
522
|
-
},
|
|
523
|
-
];
|
|
524
|
-
});
|
|
520
|
+
export function spendToday(workers: readonly ConductorWorker[]): number {
|
|
521
|
+
return workers.reduce((sum, w) => sum + w.spendToday, 0);
|
|
525
522
|
}
|
|
526
523
|
|
|
527
|
-
//
|
|
528
|
-
export function
|
|
529
|
-
|
|
530
|
-
if (
|
|
531
|
-
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);
|
|
532
535
|
}
|
|
533
536
|
|
|
534
537
|
// What `conduct approve|revise` adds when the daemon can't say whether it posted the comment.
|
|
@@ -549,26 +552,11 @@ export function answerText(action: 'approve' | 'done' | 'revise', id: number, an
|
|
|
549
552
|
return `${action === 'approve' ? 'approved' : 'asked for a revision of'} ${key}: ${where}`;
|
|
550
553
|
}
|
|
551
554
|
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
says: { failed: string; ok: string },
|
|
558
|
-
): string {
|
|
559
|
-
const lines = (text: string): string[] =>
|
|
560
|
-
text
|
|
561
|
-
.split('\n')
|
|
562
|
-
.map((l) => l.trim())
|
|
563
|
-
.filter((l) => l !== '');
|
|
564
|
-
if (run.exitCode === 0) return lines(run.stdout).join('; ') || says.ok;
|
|
565
|
-
const stderr = lines(run.stderr);
|
|
566
|
-
const from = stderr.findIndex((l) => l.startsWith('conduct: '));
|
|
567
|
-
const error = stderr
|
|
568
|
-
.slice(Math.max(from, 0))
|
|
569
|
-
.map((l) => l.replace(/^conduct: /, ''))
|
|
570
|
-
.join('; ');
|
|
571
|
-
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;
|
|
572
560
|
}
|
|
573
561
|
|
|
574
562
|
// The daemon's home as the CLI finds it (apps/conductor/src/paths.ts): CONDUCTOR_HOME, else
|
|
@@ -579,27 +567,15 @@ export function homeOf(env: { conductorHome?: string; home?: string; userProfile
|
|
|
579
567
|
return user ? `${user.replace(/[\\/]+$/, '')}/.conductor` : null;
|
|
580
568
|
}
|
|
581
569
|
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
|
|
592
|
-
const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
|
|
593
|
-
if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
|
|
594
|
-
const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
|
|
595
|
-
if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
|
|
596
|
-
return {
|
|
597
|
-
error: null,
|
|
598
|
-
inbox: items,
|
|
599
|
-
notify: notify?.status === 200 ? parseNotify(notify.body) : null,
|
|
600
|
-
readAt,
|
|
601
|
-
workers,
|
|
602
|
-
};
|
|
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;
|
|
603
579
|
}
|
|
604
580
|
|
|
605
581
|
// GET /notify's body, kept to what the mod draws; null when it isn't one.
|
|
@@ -642,41 +618,54 @@ export function pushOutcome(answer: unknown): PushOutcome {
|
|
|
642
618
|
return { detail: detail || 'PushNotification said nothing about the phone', state: 'skipped' };
|
|
643
619
|
}
|
|
644
620
|
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
const
|
|
653
|
-
|
|
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
|
+
};
|
|
654
657
|
}
|
|
655
658
|
|
|
656
659
|
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
657
660
|
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
|
|
667
|
-
if (at.getTime() <= now) at.setDate(at.getDate() + 1);
|
|
668
|
-
} else if (lower === 'tomorrow' || day !== -1) {
|
|
669
|
-
at.setHours(0, 0, 0, 0);
|
|
670
|
-
at.setDate(at.getDate() + (lower === 'tomorrow' ? 1 : (day - at.getDay() + 7) % 7 || 7));
|
|
671
|
-
} else {
|
|
672
|
-
at.setTime(new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when).getTime());
|
|
673
|
-
}
|
|
674
|
-
if (Number.isNaN(at.getTime())) {
|
|
675
|
-
return {
|
|
676
|
-
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`,
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
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');
|
|
680
669
|
}
|
|
681
670
|
|
|
682
671
|
// `/pause [<project>] [--until <when>]`, `/unpause [<project>]` and `/focus-project <project> [--until
|
|
@@ -734,6 +723,38 @@ export function overrideText(
|
|
|
734
723
|
return `${who} goes first${until}`;
|
|
735
724
|
}
|
|
736
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
|
+
|
|
737
758
|
// GET /why?item=…'s answer: the rule behind the item's newest action, in one line.
|
|
738
759
|
export function whyText(item: string, answer: ApiAnswer): string {
|
|
739
760
|
if (answer.status === 404)
|
|
@@ -753,24 +774,6 @@ export function whyText(item: string, answer: ApiAnswer): string {
|
|
|
753
774
|
return `${item}: ${what}${rule.ref}${text}`;
|
|
754
775
|
}
|
|
755
776
|
|
|
756
|
-
// `/notify` with no profile: the profile, quiet hours, Remote Control.
|
|
757
|
-
export function notifyText(notify: ConductorNotify | null, now: number): string {
|
|
758
|
-
if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
|
|
759
|
-
const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
|
|
760
|
-
return [
|
|
761
|
-
`notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
|
|
762
|
-
`Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
|
|
763
|
-
].join('\n');
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// POST /notify/profile's answer as the line to show.
|
|
767
|
-
export function profileText(name: string, answer: ApiAnswer, now: number): string {
|
|
768
|
-
if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
|
|
769
|
-
const notify = parseNotify(answer.body);
|
|
770
|
-
const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
|
|
771
|
-
return `notify profile: ${notify?.profile ?? name}${until}`;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
777
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
775
778
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
776
779
|
}
|