piher 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background jobs (pure logic): one shell command runs as its own herdr terminal, and its exit is
|
|
3
|
+
* reported once with the exit code, the last lines and the log path. The completion signal is the
|
|
4
|
+
* child terminal's own state — `run` starts a program, so core reports `finished` and its exit code;
|
|
5
|
+
* nothing is typed into a shell and no marker is scraped from a screen.
|
|
6
|
+
*
|
|
7
|
+
* This module holds no I/O: quoting, names, log slices and notice text are pure, so the policy stays
|
|
8
|
+
* testable without a core socket.
|
|
9
|
+
*/
|
|
10
|
+
import { stripAnsi } from './ansi-text.ts';
|
|
11
|
+
|
|
12
|
+
/** Transcript card for the one notice a job gets (exit, check-in). */
|
|
13
|
+
export const JOB_NOTICE_CUSTOM_TYPE = 'piher.job-exit';
|
|
14
|
+
/** Label every job pane carries, so the app and a later mount can recognize one. */
|
|
15
|
+
export const JOB_LABEL_KEY = 'piher.job';
|
|
16
|
+
/**
|
|
17
|
+
* Second label on a job pane: the facts the plugin's memory would have had. The registry is in
|
|
18
|
+
* memory, so a reload forgets a running job while core keeps it alive; this label is what lets the
|
|
19
|
+
* next mount adopt it instead of going blind. A command is metadata, not a log, so it is clipped.
|
|
20
|
+
*/
|
|
21
|
+
export const JOB_META_LABEL = 'piher.job.meta';
|
|
22
|
+
/** Command characters kept in the label; enough to name the job in a notice, never the whole script. */
|
|
23
|
+
export const META_COMMAND_MAX = 120;
|
|
24
|
+
|
|
25
|
+
/** A check-in is a glance, not a failure: the job keeps running when it fires. */
|
|
26
|
+
export const DEFAULT_TIMEOUT_S = 300;
|
|
27
|
+
export const MIN_TIMEOUT_S = 5;
|
|
28
|
+
export const MAX_TIMEOUT_S = 3600;
|
|
29
|
+
/** Lines handed to the model in a notice; the rest stays in the log. */
|
|
30
|
+
export const NOTICE_TAIL_LINES = 20;
|
|
31
|
+
/** Lines one `log` action may return, and the cap per line (progress bars print one endless line). */
|
|
32
|
+
export const MAX_LOG_LINES = 400;
|
|
33
|
+
export const MAX_LINE_CHARS = 2000;
|
|
34
|
+
/** Bytes read from either end of a log: a multi-GB build log must never be read whole. */
|
|
35
|
+
export const LOG_READ_BYTES = 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
export type JobStatus = 'running' | 'finished' | 'stopped' | 'failed';
|
|
38
|
+
|
|
39
|
+
export interface JobRecord {
|
|
40
|
+
id: string;
|
|
41
|
+
label: string | null;
|
|
42
|
+
command: string;
|
|
43
|
+
cwd: string;
|
|
44
|
+
paneId: string;
|
|
45
|
+
logPath: string;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
status: JobStatus;
|
|
48
|
+
exitCode: number | null;
|
|
49
|
+
endedAt: number | null;
|
|
50
|
+
/** One check-in per job: a second one for the same silence adds nothing. */
|
|
51
|
+
checkInSent: boolean;
|
|
52
|
+
/** The notice reached pi: the job is spent and drops out of the list the launcher reads. */
|
|
53
|
+
delivered: boolean;
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `job-1`, `job-2`, …: short enough to paste. `tag` makes an id unique across reloads, which matters
|
|
59
|
+
* because the plugin's registry is in memory: after an HMR reload the counter starts again, and a
|
|
60
|
+
* `job-1` that names a different job in the same transcript is a silently wrong answer to `status`.
|
|
61
|
+
* The `x` separator keeps the tag from reading as part of the counter: `job-1x39`, never `job-139`.
|
|
62
|
+
*/
|
|
63
|
+
export function nextJobId(sequence: number, tag = ''): string {
|
|
64
|
+
return tag ? `job-${sequence}x${tag}` : `job-${sequence}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Core terminal name: 1-32 of `a-z 0-9 - _`, unique among live terminals. The job id leads so a
|
|
69
|
+
* human reading `hc ls` or the app sees which job a pane belongs to.
|
|
70
|
+
*/
|
|
71
|
+
export function jobCoreName(id: string, label: string | null): string {
|
|
72
|
+
const slug = (label ?? '')
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
75
|
+
.replace(/^-+|-+$/g, '');
|
|
76
|
+
const base = slug ? `${id}-${slug}` : id;
|
|
77
|
+
const cut = base.slice(0, 32).replace(/-+$/g, '');
|
|
78
|
+
const safe = cut.replace(/[^a-z0-9_-]+/g, '-');
|
|
79
|
+
return safe.length > 0 ? safe : 'job';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** POSIX single-quote: the only character that cannot appear literally inside single quotes. */
|
|
83
|
+
export function shellQuote(value: string): string {
|
|
84
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The argv that runs the command as a program: `pipefail` makes the exit code the pipeline's, and
|
|
89
|
+
* `tee` keeps the pane live for a human while the log holds the whole output. Nothing is typed into
|
|
90
|
+
* a shell, so multi-line scripts, heredocs and pipes are ordinary.
|
|
91
|
+
*/
|
|
92
|
+
export function planJobArgv(command: string, logPath: string): readonly string[] {
|
|
93
|
+
const body = command.replace(/\s+$/, '');
|
|
94
|
+
// The body goes in a brace group: `|` binds tighter than `;`, so `a; b | tee log` would send `a`
|
|
95
|
+
// straight to the pane and log only `b`. On its own lines, `}` also survives a trailing `&` and a
|
|
96
|
+
// trailing `# comment`, both of which would swallow it on the same line.
|
|
97
|
+
return ['bash', '-o', 'pipefail', '-c', `{\n${body}\n} 2>&1 | tee -a ${shellQuote(logPath)}`];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A check-in seconds value: clamped, never zero, and the default when the model omits or garbles it. */
|
|
101
|
+
export function clampTimeoutS(value: unknown, fallback: number = DEFAULT_TIMEOUT_S): number {
|
|
102
|
+
const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
103
|
+
return Math.min(MAX_TIMEOUT_S, Math.max(MIN_TIMEOUT_S, n));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Terminal text as a log line: no ANSI, no carriage-return progress artifacts. */
|
|
107
|
+
export function stripOutput(text: string): string {
|
|
108
|
+
return stripAnsi(text).replace(/\r/g, '');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Last `max` lines, one bounded line each: enough to judge, never a whole log in the context. */
|
|
112
|
+
export function tailLines(text: string, max: number): string {
|
|
113
|
+
const lines = stripOutput(text).split('\n').map((line) => clipLine(line));
|
|
114
|
+
// A trailing newline is not a line: left in, it costs the tail one line of real output.
|
|
115
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
116
|
+
return lines.slice(-Math.max(1, max)).join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function clipLine(line: string): string {
|
|
120
|
+
if (line.length <= MAX_LINE_CHARS) return line;
|
|
121
|
+
return `…${line.slice(-MAX_LINE_CHARS)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What the notice shows: the log when it has content, the pane otherwise. A command that dies before
|
|
126
|
+
* `tee` starts (a shell syntax error, a `#` that swallows the pipe) leaves no log, and its output is
|
|
127
|
+
* only on the pane — reporting "No output" there would hide the very error the model must read.
|
|
128
|
+
*/
|
|
129
|
+
export function chooseTail(logText: string | null, paneText: string, max: number): string {
|
|
130
|
+
if (logText !== null && logText.trim() !== '') return tailLines(logText, max);
|
|
131
|
+
if (paneText.trim() === '') return '';
|
|
132
|
+
return tailLines(paneText, max);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The facts of a job that outlive its plugin instance. Short keys keep the label compact. */
|
|
136
|
+
export interface JobMeta {
|
|
137
|
+
name: string | null;
|
|
138
|
+
log: string;
|
|
139
|
+
started: number;
|
|
140
|
+
/** The check-in budget in seconds, so an adopted job knows how much of it is left. */
|
|
141
|
+
timeout: number;
|
|
142
|
+
command: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function encodeJobMeta(meta: JobMeta): string {
|
|
146
|
+
const command = meta.command.length > META_COMMAND_MAX
|
|
147
|
+
? `${meta.command.slice(0, META_COMMAND_MAX - 1)}…`
|
|
148
|
+
: meta.command;
|
|
149
|
+
return JSON.stringify({ n: meta.name, l: meta.log, s: meta.started, t: meta.timeout, c: command });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Tolerant on purpose: a label written by an older build must degrade, never throw. */
|
|
153
|
+
export function decodeJobMeta(raw: unknown): JobMeta | null {
|
|
154
|
+
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
155
|
+
let parsed: unknown;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(raw);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
162
|
+
const rec = parsed as Record<string, unknown>;
|
|
163
|
+
const log = typeof rec.l === 'string' ? rec.l : '';
|
|
164
|
+
const started = typeof rec.s === 'number' && Number.isFinite(rec.s) ? rec.s : 0;
|
|
165
|
+
const timeout = typeof rec.t === 'number' && Number.isFinite(rec.t) ? rec.t : 0;
|
|
166
|
+
const command = typeof rec.c === 'string' ? rec.c : '';
|
|
167
|
+
const name = typeof rec.n === 'string' && rec.n ? rec.n : null;
|
|
168
|
+
return { name, log, started, timeout, command };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What adoption needs from a terminal: the plugin reads nothing else. */
|
|
172
|
+
export interface AdoptableTerminal {
|
|
173
|
+
id: string;
|
|
174
|
+
/** The terminal that started it: core's lineage, the only boundary that is not a guess. */
|
|
175
|
+
parent?: string | null;
|
|
176
|
+
cwd: string;
|
|
177
|
+
labels: Readonly<Record<string, string>>;
|
|
178
|
+
state: 'live' | 'finished';
|
|
179
|
+
exitCode: number | null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface AdoptPlan {
|
|
183
|
+
job: JobRecord;
|
|
184
|
+
/** The child already exited while no watcher was listening: report it now. */
|
|
185
|
+
finished: boolean;
|
|
186
|
+
/** Milliseconds left before the check-in, 0 when it is already due. */
|
|
187
|
+
remainingMs: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Jobs a fresh mount should pick up: the panes still carrying this session's job label, minus the
|
|
192
|
+
* ones it already tracks. A terminal with no meta label is adopted with what core knows and a
|
|
193
|
+
* clipped command, because a job nobody watches is worse than one described imprecisely.
|
|
194
|
+
*/
|
|
195
|
+
export function planAdoption(
|
|
196
|
+
terminals: readonly AdoptableTerminal[],
|
|
197
|
+
known: ReadonlySet<string>,
|
|
198
|
+
now: number,
|
|
199
|
+
opts: { parent?: string | null } = {},
|
|
200
|
+
): AdoptPlan[] {
|
|
201
|
+
const plans: AdoptPlan[] = [];
|
|
202
|
+
const seen = new Set(known);
|
|
203
|
+
for (const terminal of terminals) {
|
|
204
|
+
const id = terminal.labels[JOB_LABEL_KEY];
|
|
205
|
+
if (typeof id !== 'string' || id === '' || seen.has(id)) continue;
|
|
206
|
+
// Another master's job is not this session's business: core reports every live terminal, so the
|
|
207
|
+
// `piher.job` label alone would let a job from another project be adopted and announced here.
|
|
208
|
+
if (opts.parent !== undefined && terminal.parent !== opts.parent) continue;
|
|
209
|
+
seen.add(id);
|
|
210
|
+
const meta = decodeJobMeta(terminal.labels[JOB_META_LABEL]);
|
|
211
|
+
const started = meta !== null && meta.started > 0 ? meta.started : now;
|
|
212
|
+
const timeout = meta !== null && meta.timeout > 0 ? meta.timeout : DEFAULT_TIMEOUT_S;
|
|
213
|
+
const elapsed = Math.max(0, now - started);
|
|
214
|
+
const job: JobRecord = {
|
|
215
|
+
id,
|
|
216
|
+
label: meta?.name ?? null,
|
|
217
|
+
command: meta !== null && meta.command !== '' ? meta.command : '(started before this reload — command unknown)',
|
|
218
|
+
cwd: terminal.cwd,
|
|
219
|
+
paneId: terminal.id,
|
|
220
|
+
logPath: meta?.log ?? '',
|
|
221
|
+
startedAt: started,
|
|
222
|
+
status: 'running',
|
|
223
|
+
// Carry core's verdict in: a job that already exited is reported with its own exit code.
|
|
224
|
+
exitCode: terminal.state === 'finished' ? terminal.exitCode : null,
|
|
225
|
+
endedAt: null,
|
|
226
|
+
// A job that already outlived its budget when it was adopted must not fire a check-in burst.
|
|
227
|
+
checkInSent: elapsed >= timeout * 1000,
|
|
228
|
+
delivered: false,
|
|
229
|
+
};
|
|
230
|
+
plans.push({
|
|
231
|
+
job,
|
|
232
|
+
finished: terminal.state === 'finished',
|
|
233
|
+
remainingMs: Math.max(0, timeout * 1000 - elapsed),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return plans;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type LogSliceResult =
|
|
240
|
+
| { ok: true; text: string; total: number; shown: number }
|
|
241
|
+
| { ok: false; error: string };
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A bounded fragment of a log: `head` or `tail`, an optional regular-expression filter, and the
|
|
245
|
+
* count of matching lines so the model knows how much it is not seeing.
|
|
246
|
+
*/
|
|
247
|
+
export function readLogSlice(
|
|
248
|
+
text: string,
|
|
249
|
+
opts: { mode?: 'head' | 'tail'; lines?: unknown; grep?: string } = {},
|
|
250
|
+
): LogSliceResult {
|
|
251
|
+
const requested = typeof opts.lines === 'number' && Number.isFinite(opts.lines) ? Math.floor(opts.lines) : 100;
|
|
252
|
+
const want = Math.min(MAX_LOG_LINES, Math.max(1, requested));
|
|
253
|
+
let filter: RegExp | null = null;
|
|
254
|
+
if (typeof opts.grep === 'string' && opts.grep.trim() !== '') {
|
|
255
|
+
try {
|
|
256
|
+
filter = new RegExp(opts.grep);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
259
|
+
return { ok: false, error: `invalid grep regular expression: ${message}` };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const all = stripOutput(text).split('\n').map((line) => clipLine(line));
|
|
263
|
+
// A trailing newline is not a line: counting it would report one line more than the file has.
|
|
264
|
+
if (all.length > 0 && all[all.length - 1] === '') all.pop();
|
|
265
|
+
const matched = filter ? all.filter((line) => filter.test(line)) : all;
|
|
266
|
+
const total = matched.length;
|
|
267
|
+
if (total === 0) return { ok: true, text: '', total: 0, shown: 0 };
|
|
268
|
+
const shownLines = opts.mode === 'head' ? matched.slice(0, want) : matched.slice(-want);
|
|
269
|
+
return { ok: true, text: shownLines.join('\n'), total, shown: shownLines.length };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** `1m 20s`, `8s`, `1h 04m`: the elapsed time as one readable token. */
|
|
273
|
+
export function formatDuration(ms: number): string {
|
|
274
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
275
|
+
if (total < 60) return `${total}s`;
|
|
276
|
+
const minutes = Math.floor(total / 60);
|
|
277
|
+
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, '0')}s`;
|
|
278
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function jobName(job: Pick<JobRecord, 'id' | 'label'>): string {
|
|
282
|
+
return job.label ? `${job.id} ("${job.label}")` : job.id;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** One-line outcome, shared by the notice, `status` and `list`. */
|
|
286
|
+
export function jobOutcome(job: JobRecord): string {
|
|
287
|
+
switch (job.status) {
|
|
288
|
+
case 'running': return 'running';
|
|
289
|
+
case 'stopped': return 'stopped';
|
|
290
|
+
case 'failed': return `could not start: ${job.error ?? 'unknown error'}`;
|
|
291
|
+
default:
|
|
292
|
+
return job.exitCode === null ? 'finished without an exit code' : `exited with code ${job.exitCode}`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Model-facing notice for a job that ended. Everything needed to act without polling: what ran,
|
|
298
|
+
* where, how it ended, the last lines, and how to read more from the log.
|
|
299
|
+
*/
|
|
300
|
+
export function formatJobNotice(job: JobRecord, tail: string): string {
|
|
301
|
+
const elapsed = job.endedAt === null ? '' : ` after ${formatDuration(job.endedAt - job.startedAt)}`;
|
|
302
|
+
const lines = [`Background job ${jobName(job)} ${jobOutcome(job)}${elapsed}.`, `Command: ${job.command}`];
|
|
303
|
+
if (tail.trim() !== '') lines.push('Last output:', tail);
|
|
304
|
+
else lines.push('No output.');
|
|
305
|
+
lines.push(
|
|
306
|
+
`Full output: ${job.logPath} — read a bounded slice with `
|
|
307
|
+
+ `bg_job {action: "log", job_id: "${job.id}", mode: "tail", lines: 200, grep: "..."}.`,
|
|
308
|
+
);
|
|
309
|
+
return lines.join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Model-facing check-in for a job that outlived its timeout. It is a glance, never a verdict: the
|
|
314
|
+
* job keeps running, and the notice says so first so a model cannot read it as a failure to retry.
|
|
315
|
+
*/
|
|
316
|
+
export function formatJobCheckIn(job: JobRecord, now: number, tail: string, logBytes: number | null): string {
|
|
317
|
+
const elapsed = formatDuration(now - job.startedAt);
|
|
318
|
+
const size = logBytes === null ? '' : `, ${Math.max(1, Math.round(logBytes / 1024))} KB of log`;
|
|
319
|
+
const lines = [
|
|
320
|
+
`Background job ${jobName(job)} was still running after ${elapsed}${size} (checked at ${clock(now)}). `
|
|
321
|
+
+ 'It was NOT stopped and needs no retry. If its exit notice has arrived since, this check-in is already history.',
|
|
322
|
+
`Command: ${job.command}`,
|
|
323
|
+
];
|
|
324
|
+
if (tail.trim() !== '') lines.push('Last output:', tail);
|
|
325
|
+
else lines.push('No output yet.');
|
|
326
|
+
lines.push(
|
|
327
|
+
`Look once with bg_job {action: "log", job_id: "${job.id}"}, keep working, or end it with `
|
|
328
|
+
+ `bg_job {action: "stop", job_id: "${job.id}"}.`,
|
|
329
|
+
);
|
|
330
|
+
return lines.join('\n');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** `16:55:14`: the moment a check-in observed the job, so a late delivery cannot be mistaken for now. */
|
|
334
|
+
export function clock(ms: number): string {
|
|
335
|
+
const d = new Date(ms);
|
|
336
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()]
|
|
337
|
+
.map((part) => String(part).padStart(2, '0'))
|
|
338
|
+
.join(':');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `status` for one job: the same facts as the notice, with the pane's address while it is live. */
|
|
342
|
+
export function formatJobStatus(job: JobRecord, now: number): string {
|
|
343
|
+
const ended = job.endedAt ?? now;
|
|
344
|
+
const lines = [
|
|
345
|
+
`job: ${job.id}${job.label ? ` (${job.label})` : ''}`,
|
|
346
|
+
`command: ${job.command}`,
|
|
347
|
+
`cwd: ${job.cwd}`,
|
|
348
|
+
`state: ${jobOutcome(job)} · ${formatDuration(ended - job.startedAt)}`,
|
|
349
|
+
`pane: ${job.paneId} · log: ${job.logPath}`,
|
|
350
|
+
];
|
|
351
|
+
return lines.join('\n');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The jobs `list` shows: running ones, plus the ones whose notice has not been handed to pi yet.
|
|
356
|
+
* A delivered job is spent — its log stays on disk and the notice carries the path — so keeping it
|
|
357
|
+
* in the list only spends the launcher's context on something it already acted on.
|
|
358
|
+
*/
|
|
359
|
+
/**
|
|
360
|
+
* What `list` answers: the jobs that still owe this session a notice. `all` also shows the delivered
|
|
361
|
+
* ones the registry keeps for `status`/`log` (`DELIVERED_TAIL` of them) and any job whose cwd sits
|
|
362
|
+
* outside `cwd`, so a master working across checkouts can see whose work is whose.
|
|
363
|
+
*/
|
|
364
|
+
export function listedJobs(
|
|
365
|
+
jobs: Iterable<JobRecord>,
|
|
366
|
+
opts: { all?: boolean; cwd?: string | null } = {},
|
|
367
|
+
): JobRecord[] {
|
|
368
|
+
const root = opts.cwd ?? null;
|
|
369
|
+
return [...jobs]
|
|
370
|
+
.filter((job) => (opts.all === true ? true : !job.delivered))
|
|
371
|
+
.filter((job) => (root === null ? true : isInside(root, job.cwd)))
|
|
372
|
+
.sort((left, right) => right.startedAt - left.startedAt);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A job belongs to a directory when it runs there or below it. */
|
|
376
|
+
export function isInside(root: string, dir: string): boolean {
|
|
377
|
+
const base = root.endsWith('/') ? root : `${root}/`;
|
|
378
|
+
return dir === root || dir.startsWith(base);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Delivered records kept for `status`/`log` on an old id; the rest are dropped, the log survives. */
|
|
382
|
+
export const DELIVERED_TAIL = 5;
|
|
383
|
+
|
|
384
|
+
/** Job ids to drop once `DELIVERED_TAIL` delivered records are kept; oldest delivery first. */
|
|
385
|
+
export function expiredDelivered(jobs: Iterable<JobRecord>, keep = DELIVERED_TAIL): string[] {
|
|
386
|
+
const delivered = [...jobs].filter((job) => job.delivered)
|
|
387
|
+
.sort((left, right) => (left.endedAt ?? 0) - (right.endedAt ?? 0));
|
|
388
|
+
return delivered.slice(0, Math.max(0, delivered.length - Math.max(0, keep))).map((job) => job.id);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** One line per job for `list`: id, state, label or command, elapsed, log path. */
|
|
392
|
+
export function formatJobListLine(job: JobRecord, now: number, sessionCwd?: string | null): string {
|
|
393
|
+
const ended = job.endedAt ?? now;
|
|
394
|
+
const name = job.label ?? job.command.replace(/\s+/g, ' ').slice(0, 60);
|
|
395
|
+
// A job outside the session's own checkout says where it ran: two projects, two answers.
|
|
396
|
+
const away = sessionCwd && !isInside(sessionCwd, job.cwd) ? ` · in ${job.cwd}` : '';
|
|
397
|
+
return `${job.id} · ${jobOutcome(job)} · ${name} · ${formatDuration(ended - job.startedAt)} · ${job.logPath}${away}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The `details` a renderer needs; never the whole notice text. */
|
|
401
|
+
export function jobNoticeDetails(job: JobRecord, now: number, checkIn: boolean): Record<string, unknown> {
|
|
402
|
+
return {
|
|
403
|
+
jobId: job.id,
|
|
404
|
+
label: job.label,
|
|
405
|
+
status: job.status,
|
|
406
|
+
exitCode: job.exitCode,
|
|
407
|
+
durationMs: (job.endedAt ?? now) - job.startedAt,
|
|
408
|
+
command: job.command,
|
|
409
|
+
logPath: job.logPath,
|
|
410
|
+
checkIn,
|
|
411
|
+
};
|
|
412
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap the master Cordis tree (D78 mount topology, D80 ecosystem adoption, D81 split).
|
|
3
|
+
*
|
|
4
|
+
* Loads group, timer and HMR plugins in order so declarations resolve, with HMR as a development-only
|
|
5
|
+
* path (`--expose-internals` plus PIHER_HMR=1); production has no watcher. Workers bypass this module
|
|
6
|
+
* (C3/D81): short-lived processes mount manually, and this module plus subagent-poller.ts are the
|
|
7
|
+
* master-only dynamic imports.
|
|
8
|
+
*/
|
|
9
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
10
|
+
import Loader from '@deepseek-ai/cordis-plugin-loader';
|
|
11
|
+
import Group from '@deepseek-ai/cordis-plugin-group';
|
|
12
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { DisposeLedger } from './ledger.ts';
|
|
15
|
+
import { cordisProvide } from './cordis-services.ts';
|
|
16
|
+
import { optionValue } from './options.ts';
|
|
17
|
+
|
|
18
|
+
export interface BootstrapHooks {
|
|
19
|
+
onDispose?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CordisApp {
|
|
23
|
+
/** Root owns subagent scopes and lifecycle effects. */
|
|
24
|
+
root: Context;
|
|
25
|
+
/** False means the loader was not mounted (bare-tree fallback). */
|
|
26
|
+
loaderReady: boolean;
|
|
27
|
+
hmrActive: boolean;
|
|
28
|
+
/** Shared ledger compensates HMR disposal and handles session_shutdown cleanup. */
|
|
29
|
+
ledger: DisposeLedger;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** HMR requires Node's --expose-internals flag (D80①). */
|
|
33
|
+
export function detectExposeInternals(): boolean {
|
|
34
|
+
return process.execArgv.some((a) => a === '--expose-internals' || a.startsWith('--expose-internals='));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function createCordisApp(hooks: BootstrapHooks = {}): Promise<CordisApp> {
|
|
38
|
+
const root = new Context();
|
|
39
|
+
const ledger = new DisposeLedger();
|
|
40
|
+
// Inject the ledger through Cordis DI because loader transforms entry config and cannot carry live instances.
|
|
41
|
+
cordisProvide(root, 'piher.ledger', ledger);
|
|
42
|
+
if (hooks.onDispose) {
|
|
43
|
+
root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
|
|
44
|
+
}
|
|
45
|
+
// Resolve relative entry names (./plugins/...) from this module's directory.
|
|
46
|
+
root.baseUrl = pathToFileURL(path.dirname(fileURLToPath(import.meta.url))).href + '/';
|
|
47
|
+
|
|
48
|
+
let loaderReady = false;
|
|
49
|
+
let hmrActive = false;
|
|
50
|
+
try {
|
|
51
|
+
await root.plugin(Loader);
|
|
52
|
+
const withLoader = root as Context & { loader?: { builtins: Record<string, unknown> } };
|
|
53
|
+
if (withLoader.loader) {
|
|
54
|
+
withLoader.loader.builtins.group = Group; // D80③: declarations resolve cordis:group through this builtin.
|
|
55
|
+
loaderReady = true;
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(`[piher] cordis loader degraded (bare tree): ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Dev only: the HMR boundary needs both node's --expose-internals and PIHER_HMR=1.
|
|
62
|
+
if (loaderReady && detectExposeInternals() && optionValue('PIHER_HMR') === '1') {
|
|
63
|
+
try {
|
|
64
|
+
const withLoader = root as Context & {
|
|
65
|
+
loader?: { create: (o: { name: string; config?: unknown }) => Promise<unknown> };
|
|
66
|
+
};
|
|
67
|
+
await withLoader.loader?.create({ name: '@deepseek-ai/cordis-plugin-timer' });
|
|
68
|
+
await withLoader.loader?.create({
|
|
69
|
+
name: '@deepseek-ai/cordis-plugin-hmr',
|
|
70
|
+
config: {
|
|
71
|
+
root: [path.dirname(fileURLToPath(import.meta.url))],
|
|
72
|
+
debounce: 200,
|
|
73
|
+
ignored: [],
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
hmrActive = true;
|
|
77
|
+
console.error('[piher] hmr active (dev posture: --expose-internals + PIHER_HMR=1)');
|
|
78
|
+
// HMR already disposes old fiber effects; disposeKey additionally retires the older pi-surface
|
|
79
|
+
// generations while keeping generations registered after the reload boundary alive (D80⑤/d87).
|
|
80
|
+
// Cordis' own HMR boundary event (not part of pi's typed Events map).
|
|
81
|
+
(root as unknown as { on(name: string, fn: (reloads: unknown) => void): void }).on('hmr/reload', (reloads: unknown) => {
|
|
82
|
+
try {
|
|
83
|
+
const files: string[] = [];
|
|
84
|
+
for (const r of (reloads as Map<unknown, { filename?: string }>).values()) {
|
|
85
|
+
if (typeof r?.filename === 'string') files.push(r.filename);
|
|
86
|
+
}
|
|
87
|
+
if (files.length > 0) {
|
|
88
|
+
const n = ledger.disposeKey(files);
|
|
89
|
+
if (n > 0) console.error(`[piher] ledger: compensated ${n} disposal(s) for ${files.join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
/* Best-effort compensation; disposal failures are non-fatal. */
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// D80②: a failed HMR mount degrades to zero watchers while the rest of the tree remains usable.
|
|
97
|
+
console.error(`[piher] hmr mount failed (degraded): ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { root, loaderReady, hmrActive, ledger };
|
|
102
|
+
}
|