pi-pier 0.1.5 → 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/package.json +1 -1
- package/schemas/role-manifest.schema.json +1 -2
- package/src/bootstrap.ts +18 -22
- package/src/core/grid-shape.ts +13 -14
- package/src/core/subagent.ts +325 -857
- package/src/core/terminal.ts +25 -28
- package/src/core/todo.ts +47 -53
- package/src/gc-core.ts +15 -15
- package/src/git-adapter.ts +104 -0
- package/src/herdr-client.ts +76 -68
- package/src/history-store.ts +70 -36
- package/src/index-locks.ts +105 -0
- package/src/index-master.ts +113 -0
- package/src/index-notices.ts +43 -0
- package/src/index-pipe.ts +73 -0
- package/src/index-runtime.ts +23 -0
- package/src/index-worker.ts +39 -0
- package/src/index.ts +134 -386
- package/src/ledger.ts +15 -17
- package/src/lock-core.ts +15 -29
- package/src/manifest-compose.ts +9 -23
- package/src/notice-buffer.ts +9 -14
- package/src/pane-title.ts +24 -24
- package/src/pi-surface.ts +22 -29
- package/src/pipe-channel.ts +67 -34
- package/src/platform-paths.ts +72 -0
- package/src/progress-core.ts +26 -24
- package/src/reconcile-core.ts +25 -24
- package/src/role-loader.ts +32 -35
- package/src/role-manifest.ts +24 -28
- package/src/roles/master.json +2 -2
- package/src/roles/worker-default.json +3 -3
- package/src/runtime-policy.ts +66 -0
- package/src/session-tail.ts +46 -45
- package/src/settle-wake-core.ts +19 -18
- package/src/slim-frame.ts +45 -44
- package/src/stale-core.ts +23 -21
- package/src/storage-layout.ts +80 -0
- package/src/subagent-core.ts +151 -94
- package/src/subagent-gc.ts +237 -0
- package/src/subagent-git-io.ts +80 -0
- package/src/subagent-launch.ts +110 -0
- package/src/subagent-poll-loop.ts +285 -0
- package/src/subagent-poller.ts +90 -0
- package/src/subagent-port.ts +27 -0
- package/src/subagent-scope.ts +4 -4
- package/src/subagent-session-io.ts +143 -0
- package/src/subagent-spawn.ts +156 -0
- package/src/terminal-core.ts +56 -54
- package/src/todo-core.ts +27 -64
- package/src/todo-read-hook.ts +28 -24
- package/src/todo-reminder-core.ts +89 -0
- package/src/todos-service.ts +36 -25
- package/src/tool-gate.ts +7 -28
- package/src/vocab.ts +18 -19
package/src/herdr-client.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* herdr socket API
|
|
2
|
+
* Minimal herdr socket API client used inside the pi extension.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* - Windows
|
|
8
|
-
* - pane.report_agent {pane_id, source, agent, state, message?, agent_session_id?, agent_session_path?}
|
|
9
|
-
*
|
|
10
|
-
* - pane.report_metadata {pane_id, source, title?, state_labels?, clear_title?, clear_state_labels?}
|
|
11
|
-
* M22
|
|
12
|
-
* - agent.list {} → {type:'agent_list', agents: AgentInfo[]}
|
|
13
|
-
* - agent.wait {target, until[], timeout_ms?} →
|
|
14
|
-
* - agent.send_keys {target, keys[]} / pane.send_text {pane_id, text}
|
|
4
|
+
* Validated against herdr 0.8.0-preview (protocol 19, Windows named pipe; see WIRE.md):
|
|
5
|
+
* - Transport is NDJSON: request {id, method, params} → response {id, result} | {id, error:{code,message}};
|
|
6
|
+
* - **Control requests use one connection per request**; the server closes it after replying;
|
|
7
|
+
* - Windows targets are named pipes whose name is the complete socket_path with a \\.\pipe\ prefix;
|
|
8
|
+
* - pane.report_agent reports {pane_id, source, agent, state, message?, agent_session_id?, agent_session_path?}
|
|
9
|
+
* where state ∈ idle|working|blocked|unknown ('done' is server-derived and is not reported by the client);
|
|
10
|
+
* - pane.report_metadata reports {pane_id, source, title?, state_labels?, clear_title?, clear_state_labels?}
|
|
11
|
+
* M22 reports only the title projection; the first upgrade also clears legacy pi-herdr chunk tokens to null;
|
|
12
|
+
* - agent.list {} → {type:'agent_list', agents: AgentInfo[]};
|
|
13
|
+
* - agent.wait {target, until[], timeout_ms?} → the matching agent, or an error on timeout;
|
|
14
|
+
* - agent.send_keys {target, keys[]} / pane.send_text {pane_id, text}.
|
|
15
15
|
*
|
|
16
|
-
* v1.1
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* The v1.1 (DESIGN.md §12) surface reports three projections, lists, spawns, injects
|
|
17
|
+
* text, sends keys, waits via agent.wait, and queries session paths. Removed operations
|
|
18
|
+
* (waitSettled marker polling, readPaneText parsing, onAgentState long-lived subscriptions)
|
|
19
|
+
* are replaced by session JSONL results (session-tail.ts) and server-side agent.wait.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Design (DESIGN.md §4.1): without herdr, fall back to Noop so pi remains independent;
|
|
22
|
+
* failures in reporting calls stay silent and never affect the main pi flow.
|
|
23
23
|
*/
|
|
24
24
|
import { REPORT_AGENT_SOURCE, type TodoItem } from './vocab.ts';
|
|
25
25
|
import { BLOCKED_LABEL_KEY, SIDEBAR_ASK_TOKEN, formatBlockedLabel, formatPaneTitle, sidebarTodoTokens, staleTokenClearance } from './pane-title.ts';
|
|
26
26
|
import { LOCK_BATCH_LIMIT, LOCK_TTL_MS } from './lock-core.ts';
|
|
27
27
|
import * as net from 'node:net';
|
|
28
28
|
|
|
29
|
-
/* ──
|
|
29
|
+
/* ── Types ──────────────────────────────────────────────────────────── */
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/** Herdr agent semantic state; schema has five observed states and 'done' is server-derived. */
|
|
32
32
|
export type HerdrAgentState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown';
|
|
33
33
|
|
|
34
34
|
export interface HerdrEnv {
|
|
@@ -38,20 +38,20 @@ export interface HerdrEnv {
|
|
|
38
38
|
tabId: string;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** report_agent
|
|
41
|
+
/** State reportable through report_agent; 'done' is derived by the server. */
|
|
42
42
|
export type PaneAgentState = 'idle' | 'working' | 'blocked' | 'unknown';
|
|
43
43
|
|
|
44
44
|
export interface AgentInfo {
|
|
45
45
|
paneId: string;
|
|
46
46
|
agent: string | null;
|
|
47
47
|
status: HerdrAgentState;
|
|
48
|
-
/** agent_session.value
|
|
48
|
+
/** agent_session.value (session ID or path), retained for recovery and navigation. */
|
|
49
49
|
session: string | null;
|
|
50
50
|
stateLabels: Record<string, string>;
|
|
51
51
|
tokens: Record<string, string | null>;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** TabInfo
|
|
54
|
+
/** TabInfo projection; fields mirror the observed tab schema. */
|
|
55
55
|
export interface TabInfo {
|
|
56
56
|
tabId: string;
|
|
57
57
|
workspaceId: string;
|
|
@@ -73,64 +73,71 @@ export function detectHerdrEnv(env: NodeJS.ProcessEnv = process.env): HerdrEnv |
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** Resolve herdr socket target (Windows named-pipe prefix vs Unix path). */
|
|
77
|
+
export function herdrSocketTarget(socketPath: string, platform: NodeJS.Platform = process.platform): string {
|
|
78
|
+
if (platform !== 'win32') return socketPath;
|
|
79
|
+
return socketPath.startsWith('\\\\.\\pipe\\') ? socketPath : `\\\\.\\pipe\\${socketPath}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
76
83
|
export interface HerdrClientLike {
|
|
77
84
|
readonly available: boolean;
|
|
78
85
|
reportAgent(state: PaneAgentState, message: string | null): Promise<void>;
|
|
79
86
|
reportAgentSession(sessionPath: string | null): Promise<void>;
|
|
80
87
|
reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null; lastWriteAt?: number | null }): Promise<void>;
|
|
81
|
-
/** M18
|
|
88
|
+
/** M18: report write-lock tokens (lock-<hash> → paneId|path); null releases, and batches stay within 16 keys. */
|
|
82
89
|
reportLockTokens(tokens: Record<string, string | null>): Promise<void>;
|
|
83
90
|
/**
|
|
84
|
-
* D93
|
|
85
|
-
*
|
|
91
|
+
* D93: sidebar agent display name (display_agent is the role name).
|
|
92
|
+
* The report persists without a TTL; null clears it back to the detected value. Best effort.
|
|
86
93
|
*/
|
|
87
94
|
reportDisplayAgent(name: string | null): Promise<void>;
|
|
88
95
|
/**
|
|
89
|
-
* D95
|
|
90
|
-
*
|
|
96
|
+
* D95: ask_user_question waiting marker (tokens['pi-ask']; null clears it).
|
|
97
|
+
* Sidebar/heatmap grading distinguishes blocked + pi-ask (ask level) from plain blocked (block level).
|
|
91
98
|
*/
|
|
92
99
|
reportAskFlag(text: string | null): Promise<void>;
|
|
93
100
|
listAgents(): Promise<AgentInfo[]>;
|
|
94
|
-
/**
|
|
101
|
+
/** Start a child pane with argv in a new tab (layout.apply), returning pane/tab IDs. */
|
|
95
102
|
spawnSubPane(opts: { label: string; command: string[]; cwd: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
|
|
96
|
-
/**
|
|
103
|
+
/** Inject text into a pane terminal (pi editor input + Enter); send_text+CR reaches it directly and is used only to start launchLine. */
|
|
97
104
|
sendPaneText(paneId: string, text: string): Promise<void>;
|
|
98
|
-
/**
|
|
105
|
+
/** Wait for an agent state server-side; return the matched state, null on timeout, and throw on error. */
|
|
99
106
|
waitAgent(paneId: string, until: HerdrAgentState[], timeoutMs: number): Promise<HerdrAgentState | null>;
|
|
100
|
-
/**
|
|
107
|
+
/** Query the child-agent session path (agent_session.value, kind=path); return null when absent. */
|
|
101
108
|
getAgentSessionPath(paneId: string): Promise<string | null>;
|
|
102
|
-
/** v1.2
|
|
109
|
+
/** v1.2: Focus a pane before adding it to a group tab. */
|
|
103
110
|
focusPane(paneId: string): Promise<void>;
|
|
104
|
-
/** v1.2
|
|
111
|
+
/** v1.2: Split a new shell pane in the current tab after focus, placing it in the group tab; return its pane ID. */
|
|
105
112
|
splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string }): Promise<string>;
|
|
106
|
-
/** v1.2
|
|
113
|
+
/** v1.2: Close a pane; observed behavior kills its process tree and herdr closes an empty tab. */
|
|
107
114
|
closePane(paneId: string): Promise<void>;
|
|
108
|
-
/** v1.2
|
|
115
|
+
/** v1.2: Create a tab with a root shell pane, returning tabId/paneId for group-tab infrastructure. */
|
|
109
116
|
createTab(opts: { workspaceId: string; label?: string; cwd?: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
|
|
110
|
-
/** v1.2
|
|
117
|
+
/** v1.2: List all panes with tab ownership for group-tab additions. */
|
|
111
118
|
listPanes(): Promise<Array<{ paneId: string; tabId: string; workspaceId: string; agentStatus: string }>>;
|
|
112
|
-
/** D91
|
|
119
|
+
/** D91: Export the tab layout tree, locating it by paneId or tabId; best effort returns null on failure. */
|
|
113
120
|
exportLayout(opts: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown } | null>;
|
|
114
|
-
/** v1.3
|
|
121
|
+
/** v1.3: List tabs (tab.list); fields follow the observed schema. */
|
|
115
122
|
tabList(): Promise<TabInfo[]>;
|
|
116
|
-
/** v1.3
|
|
123
|
+
/** v1.3: Get tab details (tab.get); return null when absent. */
|
|
117
124
|
tabGet(tabId: string): Promise<TabInfo | null>;
|
|
118
|
-
/** v1.3
|
|
125
|
+
/** v1.3: Close a tab (tab.close), cascading to its panes. */
|
|
119
126
|
tabClose(tabId: string): Promise<void>;
|
|
120
|
-
/** M14
|
|
127
|
+
/** M14: Send a key combination (pane.send_keys; Herdr key combos include ctrl+c, enter, and esc). */
|
|
121
128
|
sendPaneKeys(paneId: string, keys: string[]): Promise<void>;
|
|
122
|
-
/** M14
|
|
129
|
+
/** M14: Read the pane output buffer; recent preserves ANSI data for T6 detection by default. */
|
|
123
130
|
readPane(paneId: string, opts?: {
|
|
124
131
|
source?: 'visible' | 'recent' | 'recent_unwrapped';
|
|
125
132
|
lines?: number;
|
|
126
133
|
stripAnsi?: boolean;
|
|
127
134
|
}): Promise<{ text: string; revision: number; truncated: boolean }>;
|
|
128
|
-
/** M14
|
|
135
|
+
/** M14: Wait for output to match a substring or regex; return null on timeout and throw on error. */
|
|
129
136
|
waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<boolean | null>;
|
|
130
137
|
close(): void;
|
|
131
138
|
}
|
|
132
139
|
|
|
133
|
-
/**
|
|
140
|
+
/** Find the first string field named key by depth-first search; herdr envelopes place IDs inconsistently. */
|
|
134
141
|
function findIdIn(obj: unknown, key: string, depth = 0): string | null {
|
|
135
142
|
if (!obj || depth > 6) return null;
|
|
136
143
|
if (typeof obj === 'object') {
|
|
@@ -198,23 +205,24 @@ export class NoopHerdrClient implements HerdrClientLike {
|
|
|
198
205
|
close(): void {}
|
|
199
206
|
}
|
|
200
207
|
|
|
201
|
-
/* ──
|
|
208
|
+
/* ── Implementation ────────────────────────────────────────────────── */
|
|
202
209
|
|
|
203
210
|
export class HerdrClient implements HerdrClientLike {
|
|
204
211
|
readonly available = true;
|
|
205
212
|
private clearedStaleTokens = false;
|
|
213
|
+
private readonly env: HerdrEnv;
|
|
206
214
|
|
|
207
|
-
constructor(
|
|
215
|
+
constructor(env: HerdrEnv) {
|
|
216
|
+
this.env = env;
|
|
217
|
+
}
|
|
208
218
|
|
|
209
219
|
private target(): string {
|
|
210
|
-
|
|
211
|
-
if (process.platform !== 'win32') return p;
|
|
212
|
-
return p.startsWith('\\\\.\\pipe\\') ? p : `\\\\.\\pipe\\${p}`;
|
|
220
|
+
return herdrSocketTarget(this.env.socketPath);
|
|
213
221
|
}
|
|
214
222
|
|
|
215
223
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
224
|
+
* Control requests use one connection per request; the server closes it after replying, as observed in the protocol.
|
|
225
|
+
* Reading a complete response line finishes the request; responses are {id, result} or {id, error}.
|
|
218
226
|
*/
|
|
219
227
|
private request(method: string, params: Record<string, unknown>, timeoutMs = 15000): Promise<unknown> {
|
|
220
228
|
return new Promise((resolve, reject) => {
|
|
@@ -272,7 +280,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
272
280
|
});
|
|
273
281
|
}
|
|
274
282
|
|
|
275
|
-
/* ──
|
|
283
|
+
/* ── Reporting (silent best effort: the workbench mirror must never affect pi's main flow) ── */
|
|
276
284
|
|
|
277
285
|
async reportAgent(state: PaneAgentState, message: string | null): Promise<void> {
|
|
278
286
|
try {
|
|
@@ -284,7 +292,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
284
292
|
...(message ? { message } : {}),
|
|
285
293
|
});
|
|
286
294
|
} catch {
|
|
287
|
-
/*
|
|
295
|
+
/* Silent best effort. */
|
|
288
296
|
}
|
|
289
297
|
}
|
|
290
298
|
|
|
@@ -298,7 +306,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
298
306
|
agent_session_path: sessionPath,
|
|
299
307
|
});
|
|
300
308
|
} catch {
|
|
301
|
-
/*
|
|
309
|
+
/* Silent best effort. */
|
|
302
310
|
}
|
|
303
311
|
}
|
|
304
312
|
|
|
@@ -310,8 +318,8 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
310
318
|
lastWriteAt: meta.lastWriteAt,
|
|
311
319
|
});
|
|
312
320
|
const blocked = formatBlockedLabel(meta.items);
|
|
313
|
-
// D96
|
|
314
|
-
//
|
|
321
|
+
// D96: separate stale cleanup from the daily report—stale(16) + pi-todo(1) exceeds herdr's 16-token limit,
|
|
322
|
+
// so one rejected request would lose both title and tokens (the D93 regression root cause). Set stale in its own batch first.
|
|
315
323
|
if (!this.clearedStaleTokens) {
|
|
316
324
|
await this.request('pane.report_metadata', {
|
|
317
325
|
pane_id: this.env.paneId,
|
|
@@ -328,12 +336,12 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
328
336
|
...(blocked
|
|
329
337
|
? { state_labels: { [BLOCKED_LABEL_KEY]: blocked } }
|
|
330
338
|
: { clear_state_labels: true }),
|
|
331
|
-
// D93
|
|
339
|
+
// D93: put the todo summary in a custom token; an empty string clears the key so stale summaries do not remain.
|
|
332
340
|
tokens: sidebarTodoTokens(title),
|
|
333
341
|
ttl_ms: 86400000,
|
|
334
342
|
});
|
|
335
343
|
} catch {
|
|
336
|
-
/*
|
|
344
|
+
/* Silent best effort: the title projection must never affect pi's main flow. */
|
|
337
345
|
}
|
|
338
346
|
}
|
|
339
347
|
|
|
@@ -341,7 +349,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
341
349
|
const keys = Object.keys(tokens);
|
|
342
350
|
if (keys.length === 0) return;
|
|
343
351
|
try {
|
|
344
|
-
// schema maxProperties=16
|
|
352
|
+
// schema maxProperties=16, so split into independent best-effort requests.
|
|
345
353
|
for (let i = 0; i < keys.length; i += LOCK_BATCH_LIMIT) {
|
|
346
354
|
const batch: Record<string, string | null> = {};
|
|
347
355
|
for (const k of keys.slice(i, i + LOCK_BATCH_LIMIT)) batch[k] = tokens[k];
|
|
@@ -353,7 +361,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
353
361
|
});
|
|
354
362
|
}
|
|
355
363
|
} catch {
|
|
356
|
-
/*
|
|
364
|
+
/* Lock registration is best effort; under soft veto, failure only loses a warning and does not block. */
|
|
357
365
|
}
|
|
358
366
|
}
|
|
359
367
|
|
|
@@ -365,7 +373,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
365
373
|
...(name ? { display_agent: name } : { clear_display_agent: true }),
|
|
366
374
|
});
|
|
367
375
|
} catch {
|
|
368
|
-
/*
|
|
376
|
+
/* Silent best effort: sidebar identity is supplementary. */
|
|
369
377
|
}
|
|
370
378
|
}
|
|
371
379
|
|
|
@@ -378,11 +386,11 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
378
386
|
ttl_ms: 86400000,
|
|
379
387
|
});
|
|
380
388
|
} catch {
|
|
381
|
-
/*
|
|
389
|
+
/* Silent best effort: the human-gate marker is supplementary. */
|
|
382
390
|
}
|
|
383
391
|
}
|
|
384
392
|
|
|
385
|
-
/* ──
|
|
393
|
+
/* ── Queries and control ─────────────────────────────────────────── */
|
|
386
394
|
|
|
387
395
|
async listAgents(): Promise<AgentInfo[]> {
|
|
388
396
|
const result = (await this.request('agent.list', {})) as {
|
|
@@ -414,7 +422,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
414
422
|
}
|
|
415
423
|
|
|
416
424
|
async sendPaneText(paneId: string, text: string): Promise<void> {
|
|
417
|
-
//
|
|
425
|
+
// A trailing \r acts as Enter; observed behavior sends text to the pi editor and submits it.
|
|
418
426
|
await this.request('pane.send_text', { pane_id: paneId, text: text + '\r' });
|
|
419
427
|
}
|
|
420
428
|
|
|
@@ -489,7 +497,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
489
497
|
...(opts.paneId ? { pane_id: opts.paneId } : {}),
|
|
490
498
|
...(opts.tabId ? { tab_id: opts.tabId } : {}),
|
|
491
499
|
})) as Record<string, unknown> | null;
|
|
492
|
-
//
|
|
500
|
+
// Observed envelope: {layout:{tab_id,zoomed,root}}; accept root directly at the top level for compatibility.
|
|
493
501
|
const layout = (result?.layout ?? result) as Record<string, unknown> | null | undefined;
|
|
494
502
|
if (!layout || typeof layout !== 'object' || !('root' in layout)) return null;
|
|
495
503
|
return {
|
|
@@ -536,7 +544,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
536
544
|
lines?: number;
|
|
537
545
|
stripAnsi?: boolean;
|
|
538
546
|
} = {}): Promise<{ text: string; revision: number; truncated: boolean }> {
|
|
539
|
-
//
|
|
547
|
+
// Observed envelope: {type:'pane_read', read:{text, revision, truncated, ...}}; the payload is nested under read.
|
|
540
548
|
const result = (await this.request('pane.read', {
|
|
541
549
|
pane_id: paneId,
|
|
542
550
|
source: opts.source ?? 'recent',
|
|
@@ -569,7 +577,7 @@ export class HerdrClient implements HerdrClientLike {
|
|
|
569
577
|
}
|
|
570
578
|
|
|
571
579
|
close(): void {
|
|
572
|
-
/* v1.1
|
|
580
|
+
/* v1.1 has no long-lived connection; retain the interface semantics as a no-op. */
|
|
573
581
|
}
|
|
574
582
|
}
|
|
575
583
|
|
package/src/history-store.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Workspace history (v1.2, DESIGN.md §13.4).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Task history is append-only JSONL partitioned by cwd, mirroring pi session partitioning:
|
|
5
5
|
* <agentRoot>/herdr-pi/history/--<cwd>--/history.jsonl
|
|
6
|
-
*
|
|
6
|
+
* Records are immutable so GC can remove panes without deleting history. Recovery keeps
|
|
7
|
+
* both the sessionFile and launchCommand snapshots as fallbacks.
|
|
7
8
|
*/
|
|
8
9
|
import * as fs from 'node:fs';
|
|
9
10
|
import * as path from 'node:path';
|
|
10
|
-
import {
|
|
11
|
+
import { historyFilePath, preferredHistoryFile } from './storage-layout.ts';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
export { historyFilePath, preferredHistoryFile };
|
|
14
|
+
|
|
15
|
+
/** Legacy short/resident entries normalize to task; missing values do too, while role names pass through unchanged. */
|
|
13
16
|
export function normalizeEntryKind(kind: string | null | undefined): string {
|
|
14
17
|
if (!kind || kind === 'short' || kind === 'resident') return 'task';
|
|
15
18
|
return kind;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
/**
|
|
21
|
+
/** O6: only a .jsonl path is accepted during settlement, so invalid reports cannot overwrite a valid path. */
|
|
19
22
|
export function applyReportedSessionFile(
|
|
20
23
|
current: string | null,
|
|
21
24
|
reported: string | null | undefined,
|
|
@@ -26,57 +29,70 @@ export function applyReportedSessionFile(
|
|
|
26
29
|
|
|
27
30
|
export interface HistoryEntry {
|
|
28
31
|
taskId: string;
|
|
29
|
-
/** 'task'
|
|
32
|
+
/** 'task' or a role name; disk reads normalize short/resident to task for compatibility. */
|
|
30
33
|
kind: string;
|
|
31
34
|
paneId: string;
|
|
32
35
|
tabId: string;
|
|
33
|
-
/** v1.3
|
|
36
|
+
/** v1.3: task tab name retained for history review and revival; omitted by older entries. */
|
|
34
37
|
tabName?: string;
|
|
35
38
|
workspaceId: string;
|
|
36
39
|
cwd: string;
|
|
37
40
|
description: string;
|
|
38
|
-
/**
|
|
41
|
+
/** Child-agent session file, preferred when resuming. */
|
|
39
42
|
sessionFile: string | null;
|
|
40
|
-
/**
|
|
43
|
+
/** Launch command snapshot, used to rebuild an empty continuation when sessionFile is missing. */
|
|
41
44
|
launchCommand: string[];
|
|
42
45
|
status: 'running' | 'settled' | 'consumed' | 'closed';
|
|
43
46
|
outcome?: string | null;
|
|
44
47
|
createdAt: number;
|
|
45
|
-
/**
|
|
48
|
+
/** M13c: durable consumption time, which provides the GC TTL basis. */
|
|
46
49
|
consumedAt?: number | null;
|
|
47
50
|
closedAt?: number | null;
|
|
48
|
-
/**
|
|
51
|
+
/** Previous-generation pane ID, retained when work is redone or revived. */
|
|
49
52
|
revivedFrom?: string | null;
|
|
50
|
-
/** B5
|
|
51
|
-
*
|
|
53
|
+
/** B5: writer marker (spawn / poll-settle / poll-timeout / gc / zombie-sweep …), so
|
|
54
|
+
* multiple entries in one second remain auditable (ledger row#23/24 proves both writes). */
|
|
52
55
|
via?: string;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
/** B5
|
|
56
|
-
*
|
|
58
|
+
/** B5: inherit outcome for a closed row—when patch omits it, use the latest non-empty value
|
|
59
|
+
* for that taskId, preventing GC bookkeeping from erasing settlement results under the
|
|
60
|
+
* “latest row is state” rule. */
|
|
57
61
|
export function inheritOutcome(lastOutcome: string | null | undefined, patchOutcome: string | null | undefined): string | null {
|
|
58
62
|
if (patchOutcome !== undefined) return patchOutcome;
|
|
59
63
|
return lastOutcome ?? null;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
const HISTORY_STATUSES: Record<string, true> = {
|
|
69
|
+
running: true,
|
|
70
|
+
settled: true,
|
|
71
|
+
consumed: true,
|
|
72
|
+
closed: true,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function coerceHistoryEntry(obj: unknown): HistoryEntry | null {
|
|
76
|
+
if (obj === null || typeof obj !== 'object') return null;
|
|
77
|
+
const rec = obj as Record<string, unknown>;
|
|
78
|
+
if (typeof rec.taskId !== 'string' || rec.taskId.trim() === '') return null;
|
|
79
|
+
if (typeof rec.status !== 'string' || HISTORY_STATUSES[rec.status] !== true) return null;
|
|
80
|
+
if (rec.createdAt !== undefined && !Number.isFinite(rec.createdAt)) return null;
|
|
81
|
+
const raw = rec as unknown as HistoryEntry;
|
|
82
|
+
return { ...raw, kind: normalizeEntryKind(raw.kind) };
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
/**
|
|
85
|
+
/** Parse line by line; skip junk JSON and records missing taskId/status. */
|
|
67
86
|
export function parseHistoryEntries(text: string): HistoryEntry[] {
|
|
68
87
|
const entries: HistoryEntry[] = [];
|
|
69
88
|
for (const line of (text ?? '').split(/\r?\n/)) {
|
|
70
89
|
const t = line.trim();
|
|
71
90
|
if (!t) continue;
|
|
72
91
|
try {
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
const raw = obj as HistoryEntry;
|
|
76
|
-
entries.push({ ...raw, kind: normalizeEntryKind(raw.kind) });
|
|
77
|
-
}
|
|
92
|
+
const next = coerceHistoryEntry(JSON.parse(t));
|
|
93
|
+
if (next) entries.push(next);
|
|
78
94
|
} catch {
|
|
79
|
-
/*
|
|
95
|
+
/* Skip malformed records. */
|
|
80
96
|
}
|
|
81
97
|
}
|
|
82
98
|
return entries;
|
|
@@ -86,26 +102,44 @@ function serializeEntry(entry: HistoryEntry): string {
|
|
|
86
102
|
return JSON.stringify(entry);
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
|
|
90
|
-
|
|
105
|
+
export type HistoryReadResult =
|
|
106
|
+
| { status: 'ok'; entries: HistoryEntry[] }
|
|
107
|
+
| { status: 'missing' }
|
|
108
|
+
| { status: 'unreadable'; error: Error };
|
|
109
|
+
|
|
110
|
+
/** Distinguish missing vs permission/IO failure. readHistory still returns [] for both. */
|
|
111
|
+
export function inspectHistory(file: string): HistoryReadResult {
|
|
91
112
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
if (!fs.existsSync(file)) return { status: 'missing' };
|
|
114
|
+
return { status: 'ok', entries: parseHistoryEntries(fs.readFileSync(file, 'utf8')) };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
117
|
+
return { status: 'unreadable', error: err };
|
|
95
118
|
}
|
|
96
119
|
}
|
|
97
120
|
|
|
98
|
-
/**
|
|
99
|
-
export function
|
|
121
|
+
/** Read a history file; missing or unreadable yields []. */
|
|
122
|
+
export function readHistory(file: string): HistoryEntry[] {
|
|
123
|
+
const r = inspectHistory(file);
|
|
124
|
+
return r.status === 'ok' ? r.entries : [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type HistoryWriteResult = { ok: true } | { ok: false; error: Error };
|
|
128
|
+
|
|
129
|
+
/** Append one entry. Failures are logged and returned so the ledger is not silently lost. */
|
|
130
|
+
export function appendHistory(file: string, entry: HistoryEntry): HistoryWriteResult {
|
|
100
131
|
try {
|
|
101
132
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
102
133
|
fs.appendFileSync(file, serializeEntry(entry) + '\n');
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
return { ok: true };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
137
|
+
console.error(`[pi-herdr] history append failed: ${file}: ${err.message}`);
|
|
138
|
+
return { ok: false, error: err };
|
|
105
139
|
}
|
|
106
140
|
}
|
|
107
141
|
|
|
108
|
-
/**
|
|
142
|
+
/** Group all generations of each taskId in ascending createdAt order for review. */
|
|
109
143
|
export function generationsByTask(entries: readonly HistoryEntry[]): Map<string, HistoryEntry[]> {
|
|
110
144
|
const map = new Map<string, HistoryEntry[]>();
|
|
111
145
|
for (const e of entries) {
|
|
@@ -117,7 +151,7 @@ export function generationsByTask(entries: readonly HistoryEntry[]): Map<string,
|
|
|
117
151
|
return map;
|
|
118
152
|
}
|
|
119
153
|
|
|
120
|
-
/**
|
|
154
|
+
/** Return the newest generation for taskId, which is the one used for resume. */
|
|
121
155
|
export function latestGeneration(entries: readonly HistoryEntry[], taskId: string): HistoryEntry | null {
|
|
122
156
|
const gens = generationsByTask(entries).get(taskId);
|
|
123
157
|
if (!gens || gens.length === 0) return null;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M18 file write-lock installer (soft veto / hard block).
|
|
3
|
+
*
|
|
4
|
+
* Why: lock acquire/release/command lived in the composition root next to
|
|
5
|
+
* unrelated session wiring. The installer owns the three pi hooks and the
|
|
6
|
+
* held-lock set; fail-open on agent.list remains the collaboration policy.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import {
|
|
10
|
+
acquireTokensFor,
|
|
11
|
+
isLockTokenKey,
|
|
12
|
+
parseLockTokenValue,
|
|
13
|
+
planWriteGuard,
|
|
14
|
+
releaseTokensFor,
|
|
15
|
+
type LockAgentView,
|
|
16
|
+
} from './lock-core.ts';
|
|
17
|
+
import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
|
|
18
|
+
|
|
19
|
+
export function installWriteLocks(
|
|
20
|
+
pi: ExtensionAPI,
|
|
21
|
+
opts: {
|
|
22
|
+
client: HerdrClientLike;
|
|
23
|
+
env: HerdrEnv | null;
|
|
24
|
+
hard: boolean;
|
|
25
|
+
},
|
|
26
|
+
): void {
|
|
27
|
+
const { client, env, hard } = opts;
|
|
28
|
+
const heldLocks = new Set<string>();
|
|
29
|
+
const lockWarnByToolCall = new Map<string, string>();
|
|
30
|
+
|
|
31
|
+
async function acquireLocks(paths: readonly string[]): Promise<void> {
|
|
32
|
+
for (const p of paths) heldLocks.add(p);
|
|
33
|
+
await client.reportLockTokens(acquireTokensFor(paths, env?.paneId ?? ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pi.on('tool_call', async (event: { toolName?: string; toolCallId?: string; input?: unknown }, ctx: { cwd?: string }) => {
|
|
37
|
+
if (!client.available || !env) return;
|
|
38
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
39
|
+
let agents: LockAgentView[];
|
|
40
|
+
try {
|
|
41
|
+
agents = (await client.listAgents()).map((a) => ({ paneId: a.paneId, tokens: a.tokens }));
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const plan = planWriteGuard({
|
|
46
|
+
toolName: event.toolName ?? '',
|
|
47
|
+
input: event.input,
|
|
48
|
+
agents,
|
|
49
|
+
ownPaneId: env.paneId,
|
|
50
|
+
cwd,
|
|
51
|
+
hard,
|
|
52
|
+
});
|
|
53
|
+
if (plan.kind === 'skip') return;
|
|
54
|
+
if (plan.kind === 'block') {
|
|
55
|
+
return { block: true, reason: plan.reason };
|
|
56
|
+
}
|
|
57
|
+
if (plan.kind === 'warn' && typeof event.toolCallId === 'string') {
|
|
58
|
+
lockWarnByToolCall.set(event.toolCallId, plan.warning);
|
|
59
|
+
}
|
|
60
|
+
await acquireLocks(plan.paths);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
pi.on('tool_result', async (event: { toolCallId?: string; content?: Array<{ type: string; text: string }> }) => {
|
|
64
|
+
if (typeof event?.toolCallId !== 'string') return;
|
|
65
|
+
const warning = lockWarnByToolCall.get(event.toolCallId);
|
|
66
|
+
if (!warning) return;
|
|
67
|
+
lockWarnByToolCall.delete(event.toolCallId);
|
|
68
|
+
const content = Array.isArray(event.content) ? event.content : [];
|
|
69
|
+
return { content: [...content, { type: 'text', text: warning }] };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
pi.on('agent_settled', async () => {
|
|
73
|
+
if (heldLocks.size === 0) return;
|
|
74
|
+
const paths = [...heldLocks];
|
|
75
|
+
heldLocks.clear();
|
|
76
|
+
await client.reportLockTokens(releaseTokensFor(paths));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.registerCommand('locks', {
|
|
80
|
+
description: 'Show write locks held by this pane and all live panes (M18)',
|
|
81
|
+
handler: async (_args, ctx) => {
|
|
82
|
+
const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
|
|
83
|
+
const mine = [...heldLocks];
|
|
84
|
+
const lines = [`held by this pane (${mine.length}):`];
|
|
85
|
+
lines.push(...(mine.length ? mine.map((p) => ` ${p}`) : [' (none)']));
|
|
86
|
+
lines.push('all live locks:');
|
|
87
|
+
let any = false;
|
|
88
|
+
try {
|
|
89
|
+
for (const a of await client.listAgents()) {
|
|
90
|
+
for (const [k, v] of Object.entries(a.tokens)) {
|
|
91
|
+
if (!isLockTokenKey(k) || typeof v !== 'string' || !v) continue;
|
|
92
|
+
const parsed = parseLockTokenValue(v);
|
|
93
|
+
if (!parsed) continue;
|
|
94
|
+
any = true;
|
|
95
|
+
lines.push(` ${parsed.path} → pane ${parsed.holderPaneId}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
lines.push(' (agent.list failed)');
|
|
100
|
+
}
|
|
101
|
+
if (!any && lines[lines.length - 1] !== ' (agent.list failed)') lines.push(' (none)');
|
|
102
|
+
ui?.notify?.(lines.join('\n'), 'info');
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|