pi-pier 0.2.5 → 0.2.7
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 +35 -5
- package/package.json +1 -1
- package/skills/pi-herdr-workflow/SKILL.md +1 -1
- package/src/ask-user.ts +408 -0
- package/src/core/subagent.ts +1 -71
- package/src/core/terminal.ts +165 -16
- package/src/core/todo.ts +91 -6
- package/src/index-master.ts +6 -3
- package/src/index-runtime.ts +6 -2
- package/src/index-worker.ts +15 -6
- package/src/index.ts +58 -40
- package/src/slim-frame.ts +56 -1
- package/src/terminal-core.ts +86 -7
package/src/core/terminal.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { HerdrClientLike } from '../herdr-client.ts';
|
|
|
13
13
|
import {
|
|
14
14
|
READINESS_TIMEOUT_MS,
|
|
15
15
|
READ_MAX_CHARS,
|
|
16
|
+
TERM_REMINDER_CUSTOM_TYPE,
|
|
16
17
|
TERMINALS_CUSTOM_TYPE,
|
|
17
18
|
activeTerminalPaneIds,
|
|
18
19
|
classifyReadiness,
|
|
@@ -21,10 +22,12 @@ import {
|
|
|
21
22
|
detectFullscreenTUI,
|
|
22
23
|
foldTerminalsRegistry,
|
|
23
24
|
makeTerminalsRegistry,
|
|
25
|
+
planIdleTerminalReminder,
|
|
24
26
|
promptStrategyFor,
|
|
25
27
|
registerTerminal,
|
|
26
28
|
stripAnsi,
|
|
27
29
|
summarizeSessions,
|
|
30
|
+
terminalReminderGraceMs,
|
|
28
31
|
validateSendText,
|
|
29
32
|
validateSignal,
|
|
30
33
|
type ReadCursor,
|
|
@@ -95,7 +98,85 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
95
98
|
rebuildTerminals(eventCtx);
|
|
96
99
|
});
|
|
97
100
|
|
|
101
|
+
// Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
|
|
102
|
+
// terminals are persistent by design, so a master that exits without closing them leaks live
|
|
103
|
+
// zsh panes. Session SWITCH never fires this event (only session_start/session_tree), so
|
|
104
|
+
// switching branches cannot kill shells that the registry replays on the next session_start.
|
|
105
|
+
scoped.on('session_shutdown', async () => {
|
|
106
|
+
idleReminderTimer && clearTimeout(idleReminderTimer);
|
|
107
|
+
idleReminderTimer = null;
|
|
108
|
+
const open = terminals.filter((t) => t.status === 'open');
|
|
109
|
+
if (open.length === 0) return;
|
|
110
|
+
await Promise.allSettled(open.map((t) => client.closePane(t.paneId)));
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
terminals = terminals.map((t) =>
|
|
113
|
+
t.status === 'open' ? { ...t, status: 'closed' as const, closedAt: now } : t,
|
|
114
|
+
);
|
|
115
|
+
persistTerminals();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Turn-end nudge: remind the model to close terminals idle past the threshold (wF:p7 orphan —
|
|
119
|
+
// a one-shot background compile left a dead split in the main tab forever). Same delivery
|
|
120
|
+
// shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
|
|
121
|
+
const piSend = surface.raw as {
|
|
122
|
+
sendMessage?: (
|
|
123
|
+
message: { customType: string; content: string; display?: boolean },
|
|
124
|
+
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
125
|
+
) => Promise<void>;
|
|
126
|
+
};
|
|
127
|
+
let idleReminders = 0;
|
|
128
|
+
let idleReminderTimer: NodeJS.Timeout | null = null;
|
|
129
|
+
scoped.on('agent_start', () => {
|
|
130
|
+
if (idleReminderTimer !== null) {
|
|
131
|
+
clearTimeout(idleReminderTimer);
|
|
132
|
+
idleReminderTimer = null;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
scoped.on('agent_settled', async () => {
|
|
136
|
+
if (idleReminderTimer !== null) {
|
|
137
|
+
clearTimeout(idleReminderTimer);
|
|
138
|
+
idleReminderTimer = null;
|
|
139
|
+
}
|
|
140
|
+
const plan = planIdleTerminalReminder({
|
|
141
|
+
open: terminals.filter((t) => t.status === 'open'),
|
|
142
|
+
now: Date.now(),
|
|
143
|
+
reminders: idleReminders,
|
|
144
|
+
});
|
|
145
|
+
if (!plan.due || plan.content == null) return;
|
|
146
|
+
// 01a06ae3 goodbye-loop: mark the covered terminals nudged NOW (persisted), and deliver the
|
|
147
|
+
// notice as a queued followUp WITHOUT triggerTurn — a reminder alone must never wake the
|
|
148
|
+
// agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const ids = new Set(plan.ids);
|
|
151
|
+
terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
|
|
152
|
+
persistTerminals();
|
|
153
|
+
idleReminders += 1;
|
|
154
|
+
const content = plan.content;
|
|
155
|
+
idleReminderTimer = setTimeout(() => {
|
|
156
|
+
idleReminderTimer = null;
|
|
157
|
+
void (async () => {
|
|
158
|
+
const send = piSend.sendMessage;
|
|
159
|
+
if (typeof send !== 'function') return;
|
|
160
|
+
try {
|
|
161
|
+
await send(
|
|
162
|
+
{ customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
163
|
+
{ deliverAs: 'followUp' },
|
|
164
|
+
);
|
|
165
|
+
} catch {
|
|
166
|
+
/* Delivery failure is non-fatal; the cap already bounds retries. */
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
}, terminalReminderGraceMs());
|
|
170
|
+
idleReminderTimer.unref?.();
|
|
171
|
+
});
|
|
172
|
+
|
|
98
173
|
const TERM_READ_MAX = Number(process.env.PI_HERDR_TERM_READ_MAX ?? READ_MAX_CHARS) || READ_MAX_CHARS;
|
|
174
|
+
/** wait action: default and ceiling for the blocking output wait (an LLM call must not hang forever). */
|
|
175
|
+
const WAIT_DEFAULT_MS = 120_000;
|
|
176
|
+
const WAIT_MAX_MS = 600_000;
|
|
177
|
+
/** wait action: recent-tail size handed back on timeout so the model can decide the next move. */
|
|
178
|
+
const WAIT_TAIL_CHARS = 2_000;
|
|
179
|
+
|
|
99
180
|
const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
|
|
100
181
|
|
|
101
182
|
scoped.registerTool({
|
|
@@ -103,22 +184,30 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
103
184
|
label: 'Terminal',
|
|
104
185
|
description: [
|
|
105
186
|
'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
|
|
106
|
-
'Operations: open (create session), send (type commands), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
|
|
187
|
+
'Operations: open (create session), send (type commands), wait (block until output matches a pattern), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
|
|
107
188
|
'The shell keeps cwd, environment variables, and background processes across calls.',
|
|
108
189
|
'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
|
|
190
|
+
'Close a terminal with action: "close" as soon as the work in it is done — finished terminals are never auto-reclaimed and keep occupying a pane.',
|
|
191
|
+
'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with pattern "TERM_DONE_" — the sentinel also carries the exit code; redirect verbose output to a log file and read the file, because output between two reads is lost.',
|
|
192
|
+
'After send, use read to confirm the command actually started; multi-line text executes line-by-line. send(wait_prompt: true) refuses to queue text while a previous command is still running.',
|
|
109
193
|
].join(' '),
|
|
110
194
|
parameters: Type.Object({
|
|
111
195
|
action: Type.Union([
|
|
112
196
|
Type.Literal('open'),
|
|
113
197
|
Type.Literal('send'),
|
|
198
|
+
Type.Literal('wait'),
|
|
114
199
|
Type.Literal('read'),
|
|
115
200
|
Type.Literal('signal'),
|
|
116
201
|
Type.Literal('close'),
|
|
117
202
|
Type.Literal('list'),
|
|
118
203
|
], { description: 'Terminal operation to perform' }),
|
|
119
204
|
cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
|
|
120
|
-
terminal_id: Type.Optional(Type.String({ description: '[send|read|signal|close] Terminal id returned by open action' })),
|
|
205
|
+
terminal_id: Type.Optional(Type.String({ description: '[send|wait|read|signal|close] Terminal id returned by open action' })),
|
|
121
206
|
text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
|
|
207
|
+
wait_prompt: Type.Optional(Type.Boolean({ description: '[send] Verify the shell is back at a prompt before sending; refuses with the detected readiness instead of queueing text behind a running command' })),
|
|
208
|
+
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output' })),
|
|
209
|
+
regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
|
|
210
|
+
timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
|
|
122
211
|
pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
|
|
123
212
|
max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
|
|
124
213
|
key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
|
|
@@ -128,18 +217,33 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
128
217
|
switch (action) {
|
|
129
218
|
case 'open': return executeTerminalOpen(params, ctx);
|
|
130
219
|
case 'send': return executeTerminalSend(params);
|
|
220
|
+
case 'wait': return executeTerminalWait(params);
|
|
131
221
|
case 'read': return executeTerminalRead(params);
|
|
132
222
|
case 'signal': return executeTerminalSignal(params);
|
|
133
223
|
case 'close': return executeTerminalClose(params);
|
|
134
224
|
case 'list': return executeTerminalList();
|
|
135
225
|
default:
|
|
136
226
|
return errText(action
|
|
137
|
-
? `unknown action "${action}" (valid: open, send, read, signal, close, list)`
|
|
138
|
-
: 'action is required (open, send, read, signal, close, list)');
|
|
227
|
+
? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
|
|
228
|
+
: 'action is required (open, send, wait, read, signal, close, list)');
|
|
139
229
|
}
|
|
140
230
|
},
|
|
141
231
|
});
|
|
142
232
|
|
|
233
|
+
/** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
|
|
234
|
+
async function probeReadiness(paneId: string): Promise<'prompt' | 'silent' | 'busy'> {
|
|
235
|
+
const prompt = promptStrategyFor();
|
|
236
|
+
try {
|
|
237
|
+
const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
238
|
+
if (matched) return 'prompt';
|
|
239
|
+
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
240
|
+
return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
241
|
+
} catch {
|
|
242
|
+
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
243
|
+
return 'busy';
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
143
247
|
async function executeTerminalOpen(params, ctx) {
|
|
144
248
|
if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
|
|
145
249
|
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
@@ -160,18 +264,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
160
264
|
r.entry.paneId = paneId;
|
|
161
265
|
terminals = r.entries;
|
|
162
266
|
persistTerminals();
|
|
163
|
-
const
|
|
164
|
-
let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
|
|
165
|
-
try {
|
|
166
|
-
const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
167
|
-
if (matched) readiness = 'prompt';
|
|
168
|
-
else {
|
|
169
|
-
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
170
|
-
readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
171
|
-
}
|
|
172
|
-
} catch {
|
|
173
|
-
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
174
|
-
}
|
|
267
|
+
const readiness = await probeReadiness(paneId);
|
|
175
268
|
const text = [
|
|
176
269
|
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
177
270
|
`cwd: ${cwd}`,
|
|
@@ -185,6 +278,18 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
185
278
|
if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
186
279
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
187
280
|
if (!v.ok) return errText(v.error);
|
|
281
|
+
if (params?.wait_prompt === true) {
|
|
282
|
+
// Orchestration convention: read before writing. Text typed while a previous command still
|
|
283
|
+
// owns the foreground gets queued and fires later, which reads as "the shell ignored me".
|
|
284
|
+
// Refuse with the observed readiness instead of guessing.
|
|
285
|
+
const readiness = await probeReadiness(entry.paneId);
|
|
286
|
+
if (readiness !== 'prompt') {
|
|
287
|
+
return errText(
|
|
288
|
+
`shell is ${readiness === 'busy' ? 'busy (a previous command may still be running)' : 'silent (no prompt detected)'} — text was NOT sent. `
|
|
289
|
+
+ 'Use action read to inspect the pane, or omit wait_prompt to send anyway.',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
188
293
|
try {
|
|
189
294
|
await client.sendPaneText(entry.paneId, v.text);
|
|
190
295
|
} catch (e) {
|
|
@@ -194,6 +299,50 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
194
299
|
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
|
|
195
300
|
}
|
|
196
301
|
|
|
302
|
+
async function executeTerminalWait(params) {
|
|
303
|
+
const entry = findOpenTerminal(params?.terminal_id);
|
|
304
|
+
if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
305
|
+
const raw = typeof params?.pattern === 'string' ? params.pattern : '';
|
|
306
|
+
if (!raw) return errText('pattern is required (literal text, or a regular expression with regex: true)');
|
|
307
|
+
const useRegex = params?.regex === true;
|
|
308
|
+
if (useRegex) {
|
|
309
|
+
try {
|
|
310
|
+
new RegExp(raw);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
return errText(`invalid regex: ${(e as Error).message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
316
|
+
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
317
|
+
const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
|
|
318
|
+
let matched: boolean | null;
|
|
319
|
+
try {
|
|
320
|
+
matched = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
return errText(`wait failed (pane may be closed): ${(e as Error).message}`);
|
|
323
|
+
}
|
|
324
|
+
if (matched !== true) {
|
|
325
|
+
// wait-for-text convention: on timeout, hand back the recent tail so the model can decide
|
|
326
|
+
// the next move instead of re-polling blind.
|
|
327
|
+
let tail = '';
|
|
328
|
+
try {
|
|
329
|
+
const read = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
330
|
+
tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
|
|
331
|
+
} catch {
|
|
332
|
+
/* The pane is gone; the timeout text alone still tells the model it is stuck. */
|
|
333
|
+
}
|
|
334
|
+
const reason = matched === false ? 'no match' : 'wait unavailable';
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
|
|
337
|
+
details: { terminal_id: entry.terminalId, matched: false, pattern: raw },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
|
|
342
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
197
346
|
async function executeTerminalRead(params) {
|
|
198
347
|
let paneId: string | null = null;
|
|
199
348
|
let entry: TerminalEntry | null = null;
|
package/src/core/todo.ts
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A Cordis plugin keeps the master surface hot-swappable while allowing workers to mount the
|
|
5
5
|
* one-shot subset directly. Injected services preserve the D79 registration boundary and keep
|
|
6
|
-
* session-owned todo state in index. This module owns the tool, command, widget,
|
|
7
|
-
* reconciliation, mirroring, and agent reporting stay with the
|
|
8
|
-
* dependency cycle.
|
|
6
|
+
* session-owned todo state in index. This module owns the tool, command, widget, read hook,
|
|
7
|
+
* and master stop reminder; reconciliation, mirroring, and agent reporting stay with the
|
|
8
|
+
* session state layer to avoid a dependency cycle.
|
|
9
9
|
*/
|
|
10
10
|
import { Context } from '@deepseek-ai/cordis';
|
|
11
11
|
import { Type } from 'typebox';
|
|
12
12
|
import type { PiSurface } from '../pi-surface.ts';
|
|
13
13
|
import type { TodosService } from '../todos-service.ts';
|
|
14
14
|
import { planTodoReadHook } from '../todo-read-hook.ts';
|
|
15
|
+
import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
|
|
15
16
|
import { makeProgressUpdate } from '../subagent-core.ts';
|
|
16
17
|
import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
|
|
17
18
|
import { formatAge, isArchived } from '../stale-core.ts';
|
|
@@ -30,6 +31,8 @@ import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-wi
|
|
|
30
31
|
export interface TodoUiSlot {
|
|
31
32
|
/** Lets index lifecycle events render through the currently mounted plugin. */
|
|
32
33
|
renderWidget: (ctx: unknown) => void;
|
|
34
|
+
/** Re-render from the last seen context without a fresh event (human-gate open/close). */
|
|
35
|
+
rerenderWidget?: () => void;
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
interface TodoDeps {
|
|
@@ -40,6 +43,13 @@ interface TodoDeps {
|
|
|
40
43
|
mirrorTodos: () => void;
|
|
41
44
|
appendEntry: (customType: string, data: unknown) => void;
|
|
42
45
|
state: TodoUiSlot;
|
|
46
|
+
/** Human-gate depth (ask_user_question / herdr:blocked); >0 collapses the widget to one line. */
|
|
47
|
+
getBlockedDepth?: () => number;
|
|
48
|
+
/** Master-only unfinished-todo reminder; omitted on worker panes. */
|
|
49
|
+
stopReminder?: {
|
|
50
|
+
getBlockedDepth: () => number;
|
|
51
|
+
getRunningSubs: () => number;
|
|
52
|
+
};
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
const TOOL_DESCRIPTION = [
|
|
@@ -61,8 +71,18 @@ export const WIDGET_MAX_LINES = 10;
|
|
|
61
71
|
* completions. A +N line points to /todos when the full plan cannot fit. This fixes 01a03c0d,
|
|
62
72
|
* where active work scrolled out while only the final entries remained visible.
|
|
63
73
|
*/
|
|
64
|
-
export function widgetLines(
|
|
74
|
+
export function widgetLines(
|
|
75
|
+
items: readonly TodoItem[],
|
|
76
|
+
opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
|
|
77
|
+
): string[] {
|
|
65
78
|
if (items.length === 0) return [];
|
|
79
|
+
// Human gate open (ask_user_question waiting): the question + editor own the fixed area, so the
|
|
80
|
+
// widget collapses to the one-line summary — /todos still reaches the full list. Without this,
|
|
81
|
+
// a 10-line widget plus a multi-line question leaves almost no scrollable transcript
|
|
82
|
+
// (user-reported: both blocks visible ⇒ history range too small).
|
|
83
|
+
if ((opts?.blockedDepth ?? 0) > 0) {
|
|
84
|
+
return [`${formatTodoSummary(items)} · /todos 全量`];
|
|
85
|
+
}
|
|
66
86
|
// Collapse archived plans to two lines so stale work cannot monopolize the widget.
|
|
67
87
|
if (opts?.archivedAgeMs != null) {
|
|
68
88
|
return [
|
|
@@ -89,7 +109,7 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
|
|
|
89
109
|
|
|
90
110
|
export default function todoPlugin(ctx: Context): void {
|
|
91
111
|
const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
|
|
92
|
-
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state } =
|
|
112
|
+
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth } =
|
|
93
113
|
ctx.get('pi-herdr.todo-deps') as TodoDeps;
|
|
94
114
|
const scoped = surface.forModule(import.meta.url);
|
|
95
115
|
|
|
@@ -109,9 +129,15 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
109
129
|
: undefined;
|
|
110
130
|
}
|
|
111
131
|
|
|
132
|
+
/** Gate transitions fire outside lifecycle events; remember one ctx so rerenderWidget can target the live ui. */
|
|
133
|
+
let lastEventCtx: unknown = null;
|
|
112
134
|
function renderWidget(eventCtx: unknown): void {
|
|
135
|
+
if (eventCtx !== null && eventCtx !== undefined) lastEventCtx = eventCtx;
|
|
113
136
|
try {
|
|
114
|
-
widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, {
|
|
137
|
+
widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, {
|
|
138
|
+
archivedAgeMs: archivedAgeMs(),
|
|
139
|
+
blockedDepth: getBlockedDepth?.() ?? 0,
|
|
140
|
+
}));
|
|
115
141
|
} catch {
|
|
116
142
|
/* Older pi versions may omit widget support without disabling todo tracking. */
|
|
117
143
|
}
|
|
@@ -119,6 +145,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
119
145
|
|
|
120
146
|
// Let index lifecycle hooks call the current plugin implementation.
|
|
121
147
|
state.renderWidget = renderWidget;
|
|
148
|
+
state.rerenderWidget = () => renderWidget(lastEventCtx);
|
|
122
149
|
|
|
123
150
|
/* ── Read hook: D39 reminders and stale-plan thawing ────────────── */
|
|
124
151
|
let todoReadTurn = 0;
|
|
@@ -178,6 +205,64 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
178
205
|
};
|
|
179
206
|
});
|
|
180
207
|
|
|
208
|
+
if (stopReminder) {
|
|
209
|
+
const pi = surface.raw as {
|
|
210
|
+
sendMessage?: (
|
|
211
|
+
message: { customType: string; content: string; display?: boolean; details?: Record<string, unknown> },
|
|
212
|
+
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
213
|
+
) => Promise<void>;
|
|
214
|
+
};
|
|
215
|
+
let todoReminders = 0;
|
|
216
|
+
let lastAssistantStopReason: string | null = null;
|
|
217
|
+
let todoReminderTimer: NodeJS.Timeout | null = null;
|
|
218
|
+
function cancelTodoReminder(): void {
|
|
219
|
+
if (todoReminderTimer !== null) {
|
|
220
|
+
clearTimeout(todoReminderTimer);
|
|
221
|
+
todoReminderTimer = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
scoped.on('turn_end', async (event: unknown) => {
|
|
225
|
+
if (event === null || typeof event !== 'object' || !('message' in event)) return;
|
|
226
|
+
const msg = (event as { message: unknown }).message;
|
|
227
|
+
if (msg === null || typeof msg !== 'object') return;
|
|
228
|
+
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
229
|
+
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
230
|
+
lastAssistantStopReason = stopReason;
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
scoped.on('agent_start', () => cancelTodoReminder());
|
|
234
|
+
scoped.on('session_shutdown', () => cancelTodoReminder());
|
|
235
|
+
scoped.on('agent_settled', async () => {
|
|
236
|
+
cancelTodoReminder();
|
|
237
|
+
const plan = planStopTodoReminder({
|
|
238
|
+
lastStopReason: lastAssistantStopReason,
|
|
239
|
+
reminders: todoReminders,
|
|
240
|
+
runningSubs: stopReminder.getRunningSubs(),
|
|
241
|
+
blockedDepth: stopReminder.getBlockedDepth(),
|
|
242
|
+
items: todos.items,
|
|
243
|
+
});
|
|
244
|
+
if (!plan.due || plan.content == null) return;
|
|
245
|
+
const content = plan.content;
|
|
246
|
+
todoReminderTimer = setTimeout(() => {
|
|
247
|
+
todoReminderTimer = null;
|
|
248
|
+
void (async () => {
|
|
249
|
+
const send = pi.sendMessage;
|
|
250
|
+
if (typeof send !== 'function') return;
|
|
251
|
+
try {
|
|
252
|
+
await send(
|
|
253
|
+
{ customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
254
|
+
{ deliverAs: 'followUp', triggerTurn: true },
|
|
255
|
+
);
|
|
256
|
+
todoReminders += 1;
|
|
257
|
+
} catch {
|
|
258
|
+
/* Delivery failure is non-fatal. */
|
|
259
|
+
}
|
|
260
|
+
})();
|
|
261
|
+
}, todoReminderGraceMs());
|
|
262
|
+
todoReminderTimer.unref?.();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
181
266
|
/* ── todo_write tool ───────────────────────────────────────────── */
|
|
182
267
|
scoped.registerTool({
|
|
183
268
|
name: TODO_TOOL_NAME,
|
package/src/index-master.ts
CHANGED
|
@@ -86,9 +86,14 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
|
|
|
86
86
|
maxItems: 15,
|
|
87
87
|
mirrorTodos: m.mirrorTodos,
|
|
88
88
|
appendEntry: (customType: string, data: unknown) => {
|
|
89
|
-
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType,
|
|
89
|
+
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, d);
|
|
90
90
|
},
|
|
91
91
|
state: m.todoUi,
|
|
92
|
+
getBlockedDepth: m.getBlockedDepth,
|
|
93
|
+
stopReminder: {
|
|
94
|
+
getBlockedDepth: m.getBlockedDepth,
|
|
95
|
+
getRunningSubs: () => m.port.current?.listRunningSubs().length ?? 0,
|
|
96
|
+
},
|
|
92
97
|
});
|
|
93
98
|
await loadEntry(sessionRoot, useLoader, './core/todo.ts', todoPlugin);
|
|
94
99
|
|
|
@@ -101,12 +106,10 @@ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
|
|
|
101
106
|
deliverNotice: m.deliverNotice,
|
|
102
107
|
noticePending: m.noticePending,
|
|
103
108
|
getSessionId: m.getSessionId,
|
|
104
|
-
getBlockedDepth: m.getBlockedDepth,
|
|
105
109
|
reconcileOnSettlement: m.reconcileOnSettlement,
|
|
106
110
|
withReconcileNotes: m.withReconcileNotes,
|
|
107
111
|
claimSettleNotice: m.claimSettleNotice,
|
|
108
112
|
terminalState: terminalDeps.state,
|
|
109
|
-
todos: m.todos,
|
|
110
113
|
});
|
|
111
114
|
await loadEntry(sessionRoot, useLoader, './core/subagent.ts', subagentPlugin);
|
|
112
115
|
|
package/src/index-runtime.ts
CHANGED
|
@@ -10,14 +10,18 @@ import { detectHerdrEnv } from './herdr-client.ts';
|
|
|
10
10
|
export interface IndexMode {
|
|
11
11
|
/** PI_HERDR_SUBAGENT=1 → worker pane (no subagent tools, no cordis loader). */
|
|
12
12
|
readonly isSubagent: boolean;
|
|
13
|
-
/**
|
|
13
|
+
/** HERDR_ENV + socket + pane are present. */
|
|
14
|
+
readonly hasHerdr: boolean;
|
|
15
|
+
/** herdr pane that is not a worker → apply builtin master manifest and mount workbench plugins. */
|
|
14
16
|
readonly composeMaster: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export function planIndexMode(env: NodeJS.ProcessEnv = process.env): IndexMode {
|
|
18
20
|
const isSubagent = env.PI_HERDR_SUBAGENT === '1';
|
|
21
|
+
const hasHerdr = Boolean(detectHerdrEnv(env));
|
|
19
22
|
return {
|
|
20
23
|
isSubagent,
|
|
21
|
-
|
|
24
|
+
hasHerdr,
|
|
25
|
+
composeMaster: hasHerdr && !isSubagent,
|
|
22
26
|
};
|
|
23
27
|
}
|
package/src/index-worker.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Todo-only plugin mount (bare cordis root, no loader/hmr).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Used by worker panes and by standalone pi outside herdr, so bootstrap/subagent/terminal
|
|
5
|
+
* stay out of those processes.
|
|
6
6
|
*/
|
|
7
7
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import { Context } from '@deepseek-ai/cordis';
|
|
@@ -11,14 +11,21 @@ import todoPlugin from './core/todo.ts';
|
|
|
11
11
|
import type { TodosService } from './todos-service.ts';
|
|
12
12
|
import type { TodoUiSlot } from './core/todo.ts';
|
|
13
13
|
|
|
14
|
-
export interface
|
|
14
|
+
export interface TodoOnlyMount {
|
|
15
15
|
pi: ExtensionAPI;
|
|
16
16
|
todos: TodosService;
|
|
17
17
|
todoUi: TodoUiSlot;
|
|
18
18
|
mirrorTodos: () => void;
|
|
19
|
+
/** Human-gate depth source; collapses the todo widget to one line while a gate is open. */
|
|
20
|
+
getBlockedDepth?: () => number;
|
|
21
|
+
/** Master-only stop reminder; omit on worker panes. */
|
|
22
|
+
stopReminder?: {
|
|
23
|
+
getBlockedDepth: () => number;
|
|
24
|
+
getRunningSubs: () => number;
|
|
25
|
+
};
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
export async function
|
|
28
|
+
export async function mountTodoOnly(m: TodoOnlyMount): Promise<void> {
|
|
22
29
|
const workerRoot = new Context();
|
|
23
30
|
const workerSurface = new PiSurface(m.pi as unknown as object);
|
|
24
31
|
workerRoot.provide('pi-herdr.surface', workerSurface);
|
|
@@ -28,9 +35,11 @@ export async function mountWorkerTodo(m: WorkerPluginMount): Promise<void> {
|
|
|
28
35
|
maxItems: 15,
|
|
29
36
|
mirrorTodos: m.mirrorTodos,
|
|
30
37
|
appendEntry: (customType: string, data: unknown) => {
|
|
31
|
-
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType,
|
|
38
|
+
(m.pi as { appendEntry?: (t: string, d: unknown) => void }).appendEntry?.(customType, d);
|
|
32
39
|
},
|
|
33
40
|
state: m.todoUi,
|
|
41
|
+
...(m.getBlockedDepth ? { getBlockedDepth: m.getBlockedDepth } : {}),
|
|
42
|
+
...(m.stopReminder ? { stopReminder: m.stopReminder } : {}),
|
|
34
43
|
});
|
|
35
44
|
await workerRoot.plugin(todoPlugin);
|
|
36
45
|
m.pi.on('session_shutdown', () => {
|