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,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent board hygiene: task restore + recovery and terminal collection. Predicates live in
|
|
3
|
+
* gc-core.ts; this adapter owns core kill/remove I/O and the ticker.
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import { basename } from 'node:path';
|
|
7
|
+
import type { CoreClientLike, CoreEnv, TerminalInfo } from './core-client.ts';
|
|
8
|
+
import {
|
|
9
|
+
decideCheckoutSpaces,
|
|
10
|
+
planChannelCheck,
|
|
11
|
+
formatDeadChannelNotice,
|
|
12
|
+
formatIdleCheckoutNotice,
|
|
13
|
+
shouldClosePane,
|
|
14
|
+
shouldCloseTaskTab,
|
|
15
|
+
type CheckoutRowLike,
|
|
16
|
+
} from './gc-core.ts';
|
|
17
|
+
import type { SettlementCard } from './settlement-card.ts';
|
|
18
|
+
import { checkoutSpaceId, type CheckoutSpace } from './worktree.ts';
|
|
19
|
+
import { sessionStatePath } from './session-state.ts';
|
|
20
|
+
import { pipeRequestTo } from './pipe-channel.ts';
|
|
21
|
+
import { runtimePolicy } from './runtime-policy.ts';
|
|
22
|
+
import { sleep } from './subagent-core.ts';
|
|
23
|
+
import {
|
|
24
|
+
isClosed, type TaskEntry, type TaskObservation, type TaskRow,
|
|
25
|
+
} from './task-row.ts';
|
|
26
|
+
import { readTaskRecord, readTaskView, seedTaskView, TASK_VIEW_NAME } from './task-store.ts';
|
|
27
|
+
import type { PaneStateSlot } from './plugins/bg-job.ts';
|
|
28
|
+
|
|
29
|
+
/* ── task restore + recovery ─────────────────────────────── */
|
|
30
|
+
|
|
31
|
+
/** S53: `terminal.kill` then `terminal.rm` — the app closes the pane, and its tab goes with the last
|
|
32
|
+
* terminal. A terminal that already exited is not an error, and neither is one core reaped itself. */
|
|
33
|
+
export async function retirePane(
|
|
34
|
+
client: Pick<CoreClientLike, 'kill' | 'remove'>,
|
|
35
|
+
paneId: string,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
try { await client.kill(paneId); } catch { /* already exited */ }
|
|
38
|
+
try { await client.remove(paneId); } catch { /* core reaps finished terminals */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TaskBoardHost {
|
|
42
|
+
client: CoreClientLike;
|
|
43
|
+
tasks: Map<string, TaskEntry>;
|
|
44
|
+
panes: Map<string, string>;
|
|
45
|
+
/** Repository the restore reads records from (the session's own checkout). */
|
|
46
|
+
restoreCwd: () => string;
|
|
47
|
+
/** This session's transcript path; null when it has none (S25: the view stays in memory). */
|
|
48
|
+
sessionFile: () => string | null;
|
|
49
|
+
/** In-memory view: the task ids this session delegated, in order. */
|
|
50
|
+
viewIds: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Seed one memory entry from its record. Unresolved rows arrive running when no report was
|
|
54
|
+
* delivered yet, consumed when it was — so the recovery never re-arms a finished row. */
|
|
55
|
+
function seedEntryFromRow(host: Pick<TaskBoardHost, 'tasks' | 'panes'>, row: TaskRow): void {
|
|
56
|
+
if (isClosed(row) || host.tasks.has(row.taskId)) return;
|
|
57
|
+
const entry: TaskEntry = {
|
|
58
|
+
...row,
|
|
59
|
+
gen: { ...row.gen },
|
|
60
|
+
...(row.worktree ? { worktree: { ...row.worktree } } : {}),
|
|
61
|
+
};
|
|
62
|
+
host.tasks.set(row.taskId, entry);
|
|
63
|
+
if (row.gen.paneId) host.panes.set(row.gen.paneId, row.taskId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Restore on `session_start` (and `session_tree`): read the view, then each referenced record,
|
|
68
|
+
* drop closed rows, seed memory. Additive: rows this session already holds are kept, and a
|
|
69
|
+
* record that now says closed retires its memory row so every surface drops it. A record the
|
|
70
|
+
* session's own repository does not hold reads as "no record" (S6) and is skipped.
|
|
71
|
+
*/
|
|
72
|
+
export function restoreTasks(host: TaskBoardHost, cwd?: string): void {
|
|
73
|
+
try {
|
|
74
|
+
const ids = readTaskView(host.sessionFile());
|
|
75
|
+
if (ids) for (const id of ids) if (!host.viewIds.includes(id)) host.viewIds.push(id);
|
|
76
|
+
const repo = cwd ?? host.restoreCwd();
|
|
77
|
+
for (const taskId of [...host.viewIds]) {
|
|
78
|
+
const row = readTaskRecord(repo, taskId);
|
|
79
|
+
if (!row) continue;
|
|
80
|
+
const known = host.tasks.get(taskId);
|
|
81
|
+
// `close` is final across sessions (S24): the record's decision wins over memory, and it
|
|
82
|
+
// only ever moves one way, so adopting it here can never resurrect a task.
|
|
83
|
+
if (known && row.closedAt !== null) known.closedAt = row.closedAt;
|
|
84
|
+
if (isClosed(row)) continue;
|
|
85
|
+
if (!known) seedEntryFromRow(host, row);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
/* recovery failure must not block the live session */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A fork inherits the state it branched from: copy the parent's view document once, before the
|
|
94
|
+
* restore reads it. Records are shared, not copied: the fork continues the same tasks.
|
|
95
|
+
*/
|
|
96
|
+
export function seedForkView(sessionFile: string | null, previousSessionFile: string | null | undefined): void {
|
|
97
|
+
const to = sessionStatePath(sessionFile ?? null, TASK_VIEW_NAME);
|
|
98
|
+
const from = sessionStatePath(previousSessionFile ?? null, TASK_VIEW_NAME);
|
|
99
|
+
seedTaskView(to, from);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ── GC ─────────────────────────────────────────────────────────── */
|
|
103
|
+
|
|
104
|
+
interface GcHost {
|
|
105
|
+
client: CoreClientLike;
|
|
106
|
+
env: CoreEnv | null;
|
|
107
|
+
tasks: Map<string, TaskEntry>;
|
|
108
|
+
/** Refreshed from the pass snapshot; feeds only the state text surfaces print (S26). */
|
|
109
|
+
noteObservation?: (taskId: string, obs: TaskObservation) => void;
|
|
110
|
+
terminalState: PaneStateSlot;
|
|
111
|
+
noticePending?: () => ReadonlySet<string>;
|
|
112
|
+
/** The app's window on a checkout. Closing it ends the terminals inside, so only an empty one goes. */
|
|
113
|
+
checkoutSpace?: CheckoutSpace;
|
|
114
|
+
/** Settlement notice injector: buffers while the master is busy, flushes at turn_end. */
|
|
115
|
+
deliverNotice?: (content: string, paneId?: string, card?: SettlementCard | null) => Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface GcController {
|
|
119
|
+
runGcSafely(): Promise<void>;
|
|
120
|
+
onTurnStart(): Promise<void>;
|
|
121
|
+
startTicker(ctx: Context): void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createGcController(h: GcHost): GcController {
|
|
125
|
+
let prevTurnStart = Date.now();
|
|
126
|
+
let gcRunning = false;
|
|
127
|
+
/** Path → the settlement a checkout reminder was already delivered for. */
|
|
128
|
+
const remindedCheckouts = new Map<string, number>();
|
|
129
|
+
/** Panes whose dead channel was already announced: one notice per loss, cleared when it answers. */
|
|
130
|
+
const deadChannelNotified = new Set<string>();
|
|
131
|
+
|
|
132
|
+
/** A checkout's space is nobody else's to sweep: close it when empty, and remind the master once
|
|
133
|
+
* about the window a finished-but-open worker left behind. Never ends a task, never ends a pane. */
|
|
134
|
+
async function sweepCheckoutSpaces(ttlMs: number): Promise<void> {
|
|
135
|
+
const rows: CheckoutRowLike[] = [...h.tasks.values()]
|
|
136
|
+
.filter((e) => e.worktree?.path)
|
|
137
|
+
.map((e) => ({
|
|
138
|
+
kind: e.kind,
|
|
139
|
+
consumedAt: e.gen.consumedAt ?? null,
|
|
140
|
+
closedAt: e.closedAt,
|
|
141
|
+
worktreePath: e.worktree?.path ?? null,
|
|
142
|
+
}));
|
|
143
|
+
if (rows.length === 0) return;
|
|
144
|
+
const sweep = decideCheckoutSpaces({ rows, ttlMs, now: Date.now(), remindedAt: remindedCheckouts });
|
|
145
|
+
for (const path of sweep.close) {
|
|
146
|
+
await h.checkoutSpace?.close(checkoutSpaceId(path)).catch(() => null);
|
|
147
|
+
}
|
|
148
|
+
for (const { path, since } of sweep.remind) {
|
|
149
|
+
const idle = [...h.tasks.values()].filter((e) => e.worktree?.path === path);
|
|
150
|
+
const notice = formatIdleCheckoutNotice({
|
|
151
|
+
name: basename(path),
|
|
152
|
+
minutes: Math.round(ttlMs / 60000),
|
|
153
|
+
panes: idle.map((e) => e.gen.paneId),
|
|
154
|
+
});
|
|
155
|
+
try {
|
|
156
|
+
await h.deliverNotice?.(notice, idle[0]?.gen.paneId);
|
|
157
|
+
} catch {
|
|
158
|
+
continue; // refused (master mid-turn): the next tick retries the same reminder
|
|
159
|
+
}
|
|
160
|
+
remindedCheckouts.set(path, since);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function gcPass(): Promise<void> {
|
|
165
|
+
if (h.tasks.size === 0) return;
|
|
166
|
+
const ttlMs = runtimePolicy.sessionTtlSeconds * 1000;
|
|
167
|
+
const autoCloseTabs = ttlMs > 0;
|
|
168
|
+
// The periodic pass reads the children snapshot instead of listing terminals every tick; a
|
|
169
|
+
// blind watch (core unreachable) skips the pass rather than closing rows on a stale view.
|
|
170
|
+
const watch = h.client.watchChildren();
|
|
171
|
+
await watch.ready();
|
|
172
|
+
if (!watch.connected) return;
|
|
173
|
+
const terminals = watch.list().filter((t) => t.state === 'live');
|
|
174
|
+
const statuses = new Map(terminals.map((t) => [t.id, t.status]));
|
|
175
|
+
const tabOf = (t: TerminalInfo): string => t.labels['herdr.tab'] ?? '';
|
|
176
|
+
const liveIds = new Set(terminals.map((t) => t.id));
|
|
177
|
+
// The snapshot is this pass's observation: it refreshes the cache displays read from, and
|
|
178
|
+
// nothing else — housekeeping never ends a task, so a stale view can only mislabel one (S26).
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
for (const e of h.tasks.values()) {
|
|
181
|
+
if (!e.gen.paneId) continue;
|
|
182
|
+
h.noteObservation?.(e.taskId, {
|
|
183
|
+
paneExists: liveIds.has(e.gen.paneId),
|
|
184
|
+
agentStatus: statuses.get(e.gen.paneId) ?? null,
|
|
185
|
+
lastActivityMs: null,
|
|
186
|
+
at: now,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const termPaneIds = h.terminalState.activePaneIds();
|
|
190
|
+
const pendingNoticeIds = h.noticePending?.() ?? new Set<string>();
|
|
191
|
+
|
|
192
|
+
const byTab = new Map<string, TaskEntry[]>();
|
|
193
|
+
for (const e of h.tasks.values()) {
|
|
194
|
+
if (!e.gen.tabId) continue;
|
|
195
|
+
const arr = byTab.get(e.gen.tabId) ?? [];
|
|
196
|
+
arr.push(e);
|
|
197
|
+
byTab.set(e.gen.tabId, arr);
|
|
198
|
+
}
|
|
199
|
+
const mainTabId = (await h.client.placementLabels())['herdr.tab'] ?? '';
|
|
200
|
+
|
|
201
|
+
/** Housekeeping never ends a task: it reclaims panes, tabs and checkout spaces, and the rows
|
|
202
|
+
* stay exactly as the master left them — only `close` writes `closed` (S8, S9). */
|
|
203
|
+
/** The pair `close` and a one-shot settlement use as well. */
|
|
204
|
+
const retire = (id: string): Promise<void> => retirePane(h.client, id);
|
|
205
|
+
|
|
206
|
+
for (const [tabId, entries] of byTab) {
|
|
207
|
+
if (tabId === mainTabId) continue;
|
|
208
|
+
const tabTerminals = terminals.filter((t) => tabOf(t) === tabId);
|
|
209
|
+
// A tab with no terminals holds no live work; its rows stay listed as `no pane` until the
|
|
210
|
+
// master closes them or sends them back to life.
|
|
211
|
+
if (tabTerminals.length === 0) continue;
|
|
212
|
+
if (!autoCloseTabs) continue;
|
|
213
|
+
if (!shouldCloseTaskTab({
|
|
214
|
+
entries: entries.map((e) => ({
|
|
215
|
+
kind: e.kind, consumedAt: e.gen.consumedAt, closedAt: e.closedAt,
|
|
216
|
+
})),
|
|
217
|
+
paneStatuses: tabTerminals.map((t) => t.status),
|
|
218
|
+
ttlMs,
|
|
219
|
+
now: Date.now(),
|
|
220
|
+
})) continue;
|
|
221
|
+
// Live terminal panes and not-yet-delivered settlement notices keep the tab alive.
|
|
222
|
+
if (tabTerminals.some((t) => termPaneIds.has(t.id) || pendingNoticeIds.has(t.id))) continue;
|
|
223
|
+
for (const t of tabTerminals) await retire(t.id);
|
|
224
|
+
await sleep(300);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const closableTaskTabIds = new Set([...byTab.keys()].filter((t) => t !== mainTabId));
|
|
228
|
+
const candidates = [...h.tasks.values()].filter(
|
|
229
|
+
(e) => e.gen.consumedAt !== null && !isClosed(e)
|
|
230
|
+
&& !(e.gen.tabId && closableTaskTabIds.has(e.gen.tabId)),
|
|
231
|
+
);
|
|
232
|
+
for (const e of candidates) {
|
|
233
|
+
if (termPaneIds.has(e.gen.paneId) || pendingNoticeIds.has(e.gen.paneId)) continue;
|
|
234
|
+
// Never collect self, whatever the board says: a poisoned entry once closed the master's terminal.
|
|
235
|
+
if (h.env?.terminalId && e.gen.paneId === h.env.terminalId) continue;
|
|
236
|
+
if (!shouldClosePane({
|
|
237
|
+
consumedAt: e.gen.consumedAt ?? null,
|
|
238
|
+
status: statuses.get(e.gen.paneId),
|
|
239
|
+
prevTurnStart,
|
|
240
|
+
lifecycle: e.lifecycle,
|
|
241
|
+
})) continue;
|
|
242
|
+
// A terminal the snapshot does not list is left alone: its row stays truthful, and the next
|
|
243
|
+
// pass retries the decision instead of acting on a stale view.
|
|
244
|
+
if (statuses.get(e.gen.paneId) === undefined) continue;
|
|
245
|
+
await retire(e.gen.paneId);
|
|
246
|
+
await sleep(300);
|
|
247
|
+
}
|
|
248
|
+
await sweepCheckoutSpaces(ttlMs);
|
|
249
|
+
await sweepChannels(statuses);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A worker's message channel is what makes a follow-up possible at all. Ping it and say so once
|
|
253
|
+
* when it is gone: the alternative is the master finding out on the next `send`, which is exactly
|
|
254
|
+
* when the message matters. Never settles, never closes, never touches the row. */
|
|
255
|
+
async function sweepChannels(statuses: ReadonlyMap<string, string>): Promise<void> {
|
|
256
|
+
for (const e of [...h.tasks.values()]) {
|
|
257
|
+
if (!e.gen.paneId) continue;
|
|
258
|
+
const status = statuses.get(e.gen.paneId);
|
|
259
|
+
if (planChannelCheck({
|
|
260
|
+
closed: isClosed(e),
|
|
261
|
+
consumed: e.gen.consumedAt !== null,
|
|
262
|
+
terminalStatus: status,
|
|
263
|
+
}) === 'skip') continue;
|
|
264
|
+
// Past the planner the terminal is listed and reported: the notice may name its state.
|
|
265
|
+
const reported = status ?? 'unknown';
|
|
266
|
+
let ok = false;
|
|
267
|
+
try {
|
|
268
|
+
ok = (await pipeRequestTo(e.cwd, e.gen.paneId, { type: 'ping', id: `hb-${Date.now()}` }, 1_500)).type === 'ok';
|
|
269
|
+
} catch {
|
|
270
|
+
ok = false;
|
|
271
|
+
}
|
|
272
|
+
if (ok) {
|
|
273
|
+
deadChannelNotified.delete(e.gen.paneId);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (deadChannelNotified.has(e.gen.paneId)) continue;
|
|
277
|
+
deadChannelNotified.add(e.gen.paneId);
|
|
278
|
+
try {
|
|
279
|
+
await h.deliverNotice?.(formatDeadChannelNotice({ paneId: e.gen.paneId, taskId: e.taskId, status: reported }), e.gen.paneId, null);
|
|
280
|
+
} catch {
|
|
281
|
+
/* the next pass re-announces: the latch is only set for a delivered notice */
|
|
282
|
+
deadChannelNotified.delete(e.gen.paneId);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function runGcSafely(): Promise<void> {
|
|
288
|
+
if (gcRunning) return;
|
|
289
|
+
gcRunning = true;
|
|
290
|
+
try {
|
|
291
|
+
await gcPass();
|
|
292
|
+
} catch { /* next turn/tick retries */ } finally {
|
|
293
|
+
gcRunning = false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
runGcSafely,
|
|
299
|
+
async onTurnStart(): Promise<void> {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
await runGcSafely();
|
|
302
|
+
prevTurnStart = now;
|
|
303
|
+
},
|
|
304
|
+
startTicker(ctx: Context): void {
|
|
305
|
+
const gcTickMs = runtimePolicy.gcTickMs;
|
|
306
|
+
const gcTicker = gcTickMs > 0 ? setInterval(() => { void runGcSafely(); }, gcTickMs) : null;
|
|
307
|
+
ctx.effect(() => () => {
|
|
308
|
+
clearInterval(gcTicker ?? undefined);
|
|
309
|
+
}, 'gc-ticker');
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|