pi-pier 0.2.3 → 0.2.5
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 +3 -4
- package/package.json +1 -1
- package/skills/pi-herdr-workflow/SKILL.md +149 -77
- package/src/core/subagent.ts +245 -261
- package/src/core/terminal.ts +200 -232
- package/src/core/todo.ts +10 -75
- package/src/index.ts +49 -22
- package/src/notice-buffer.ts +2 -2
- package/src/roles/master.json +4 -4
- package/src/roles/worker-default.json +3 -12
- package/src/slim-frame.ts +120 -11
- package/src/subagent-core.ts +3 -3
- package/src/subagent-poll-loop.ts +1 -1
- package/src/subagent-session-io.ts +5 -1
- package/src/terminal-core.ts +3 -3
- package/src/todo-window.ts +82 -0
package/src/core/terminal.ts
CHANGED
|
@@ -99,258 +99,226 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
99
99
|
const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
|
|
100
100
|
|
|
101
101
|
scoped.registerTool({
|
|
102
|
-
name: '
|
|
103
|
-
label: 'Terminal
|
|
102
|
+
name: 'terminal',
|
|
103
|
+
label: 'Terminal',
|
|
104
104
|
description: [
|
|
105
|
-
'
|
|
106
|
-
'
|
|
107
|
-
'
|
|
108
|
-
'
|
|
109
|
-
'A human can also type into the pane directly; terminal_close closes it.',
|
|
105
|
+
'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).',
|
|
107
|
+
'The shell keeps cwd, environment variables, and background processes across calls.',
|
|
108
|
+
'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
|
|
110
109
|
].join(' '),
|
|
111
110
|
parameters: Type.Object({
|
|
112
|
-
|
|
111
|
+
action: Type.Union([
|
|
112
|
+
Type.Literal('open'),
|
|
113
|
+
Type.Literal('send'),
|
|
114
|
+
Type.Literal('read'),
|
|
115
|
+
Type.Literal('signal'),
|
|
116
|
+
Type.Literal('close'),
|
|
117
|
+
Type.Literal('list'),
|
|
118
|
+
], { description: 'Terminal operation to perform' }),
|
|
119
|
+
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' })),
|
|
121
|
+
text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
|
|
122
|
+
pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
|
|
123
|
+
max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
|
|
124
|
+
key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
|
|
113
125
|
}),
|
|
114
126
|
async execute(_tc, params, _sig, _upd, ctx) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
: (ctx
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
|
|
128
|
-
} catch (e) {
|
|
129
|
-
return errText(`failed to split terminal pane: ${(e as Error).message}`);
|
|
130
|
-
}
|
|
131
|
-
r.entry.paneId = paneId;
|
|
132
|
-
terminals = r.entries;
|
|
133
|
-
persistTerminals();
|
|
134
|
-
const prompt = promptStrategyFor();
|
|
135
|
-
let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
|
|
136
|
-
try {
|
|
137
|
-
const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
138
|
-
if (matched) readiness = 'prompt';
|
|
139
|
-
else {
|
|
140
|
-
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
141
|
-
readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
142
|
-
}
|
|
143
|
-
} catch {
|
|
144
|
-
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
127
|
+
const action = typeof params?.action === 'string' ? params.action : '';
|
|
128
|
+
switch (action) {
|
|
129
|
+
case 'open': return executeTerminalOpen(params, ctx);
|
|
130
|
+
case 'send': return executeTerminalSend(params);
|
|
131
|
+
case 'read': return executeTerminalRead(params);
|
|
132
|
+
case 'signal': return executeTerminalSignal(params);
|
|
133
|
+
case 'close': return executeTerminalClose(params);
|
|
134
|
+
case 'list': return executeTerminalList();
|
|
135
|
+
default:
|
|
136
|
+
return errText(action
|
|
137
|
+
? `unknown action "${action}" (valid: open, send, read, signal, close, list)`
|
|
138
|
+
: 'action is required (open, send, read, signal, close, list)');
|
|
145
139
|
}
|
|
146
|
-
const text = [
|
|
147
|
-
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
148
|
-
`cwd: ${cwd}`,
|
|
149
|
-
`readiness: ${readiness}${readiness !== 'prompt' ? ' (prompt not detected yet — check with terminal_read before sending)' : ''}`,
|
|
150
|
-
].join('\n');
|
|
151
|
-
return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
|
|
152
140
|
},
|
|
153
141
|
});
|
|
154
142
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
143
|
+
async function executeTerminalOpen(params, ctx) {
|
|
144
|
+
if (!client.available || !env) return errText('terminal tools require a herdr-managed pane');
|
|
145
|
+
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
146
|
+
: (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
147
|
+
const r = registerTerminal(terminals, {
|
|
148
|
+
paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
|
|
149
|
+
tabId: env.tabId,
|
|
150
|
+
cwd,
|
|
151
|
+
createdAt: Date.now(),
|
|
152
|
+
});
|
|
153
|
+
if (!r.ok) return errText(r.error);
|
|
154
|
+
let paneId: string;
|
|
155
|
+
try {
|
|
156
|
+
paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return errText(`failed to split terminal pane: ${(e as Error).message}`);
|
|
159
|
+
}
|
|
160
|
+
r.entry.paneId = paneId;
|
|
161
|
+
terminals = r.entries;
|
|
162
|
+
persistTerminals();
|
|
163
|
+
const prompt = promptStrategyFor();
|
|
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 });
|
|
176
171
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
172
|
+
} catch {
|
|
173
|
+
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
174
|
+
}
|
|
175
|
+
const text = [
|
|
176
|
+
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
177
|
+
`cwd: ${cwd}`,
|
|
178
|
+
`readiness: ${readiness}${readiness !== 'prompt' ? ' (prompt not detected yet — check with action read before sending)' : ''}`,
|
|
179
|
+
].join('\n');
|
|
180
|
+
return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
|
|
181
|
+
}
|
|
181
182
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}),
|
|
196
|
-
async execute(_tc, params) {
|
|
197
|
-
let paneId: string | null = null;
|
|
198
|
-
let entry: TerminalEntry | null = null;
|
|
199
|
-
if (typeof params?.terminal_id === 'string') {
|
|
200
|
-
entry = findOpenTerminal(params.terminal_id);
|
|
201
|
-
if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see terminal_list)`);
|
|
202
|
-
paneId = entry.paneId;
|
|
203
|
-
} else if (typeof params?.pane_id === 'string') {
|
|
204
|
-
// T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
|
|
205
|
-
try {
|
|
206
|
-
const panes = await client.listPanes();
|
|
207
|
-
const target = panes.find((p) => p.paneId === params.pane_id);
|
|
208
|
-
const ownTab = target?.tabId && target.tabId === env?.tabId;
|
|
209
|
-
const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
|
|
210
|
-
if (!target || (!ownTab && !ownTerminal)) {
|
|
211
|
-
return errText('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
|
|
212
|
-
}
|
|
213
|
-
paneId = params.pane_id;
|
|
214
|
-
} catch (e) {
|
|
215
|
-
return errText(`pane lookup failed: ${(e as Error).message}`);
|
|
216
|
-
}
|
|
217
|
-
} else {
|
|
218
|
-
return errText('provide terminal_id (or pane_id for a direct own-tab read)');
|
|
219
|
-
}
|
|
220
|
-
const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
|
|
221
|
-
? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
|
|
222
|
-
let read: { text: string; revision: number; truncated: boolean };
|
|
223
|
-
try {
|
|
224
|
-
read = await client.readPane(paneId, { stripAnsi: false });
|
|
225
|
-
} catch (e) {
|
|
226
|
-
return errText(`read failed (pane may be closed): ${(e as Error).message}`);
|
|
227
|
-
}
|
|
228
|
-
// T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
|
|
229
|
-
const tui = detectFullscreenTUI(read.text);
|
|
230
|
-
if (tui.detected) {
|
|
231
|
-
const head = stripAnsi(read.text).slice(0, 200);
|
|
232
|
-
const text = [
|
|
233
|
-
'FULLSCREEN_TUI_DETECTED: this terminal is running a fullscreen program (e.g. vim/less/top); its screen output is not readable here.',
|
|
234
|
-
'Suggestions:',
|
|
235
|
-
'1. Run such programs in a native herdr pane where the human can interact directly.',
|
|
236
|
-
'2. Use non-interactive alternatives (cat/grep instead of less; edit/write tools instead of vim).',
|
|
237
|
-
`Raw head (first 200 chars): ${head}`,
|
|
238
|
-
].join('\n');
|
|
239
|
-
return { content: [{ type: 'text', text }], details: { error: 'FULLSCREEN_TUI_DETECTED' } };
|
|
240
|
-
}
|
|
241
|
-
const prev: ReadCursor | null = entry
|
|
242
|
-
? (entry.readRevision != null
|
|
243
|
-
? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
|
|
244
|
-
: null)
|
|
245
|
-
: null;
|
|
246
|
-
const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
|
|
247
|
-
if (entry) {
|
|
248
|
-
entry.readRevision = inc.cursor.revision;
|
|
249
|
-
entry.readLen = inc.cursor.len;
|
|
250
|
-
entry.readTail = inc.cursor.tail;
|
|
251
|
-
entry.readEoTail = inc.cursor.eoTail;
|
|
252
|
-
touchTerminal(entry);
|
|
253
|
-
}
|
|
254
|
-
if (inc.mode === 'none') {
|
|
255
|
-
return { content: [{ type: 'text', text: 'no new output' }], details: { mode: 'none', revision: read.revision } };
|
|
256
|
-
}
|
|
257
|
-
const output = stripAnsi(inc.text);
|
|
258
|
-
const text = output.length ? output : '(empty)';
|
|
259
|
-
return {
|
|
260
|
-
content: [{ type: 'text', text }],
|
|
261
|
-
details: { mode: inc.mode, revision: read.revision, hard_capped: inc.hardCapped, source_revision_truncated: read.truncated },
|
|
262
|
-
};
|
|
263
|
-
},
|
|
264
|
-
});
|
|
183
|
+
async function executeTerminalSend(params) {
|
|
184
|
+
const entry = findOpenTerminal(params?.terminal_id);
|
|
185
|
+
if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
186
|
+
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
187
|
+
if (!v.ok) return errText(v.error);
|
|
188
|
+
try {
|
|
189
|
+
await client.sendPaneText(entry.paneId, v.text);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return errText(`send failed (pane may be closed): ${(e as Error).message}`);
|
|
192
|
+
}
|
|
193
|
+
touchTerminal(entry);
|
|
194
|
+
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
|
|
195
|
+
}
|
|
265
196
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
key: Type.String({ description: 'One of: ctrl+c, ctrl+d, ctrl+z, esc, enter' }),
|
|
276
|
-
}),
|
|
277
|
-
async execute(_tc, params) {
|
|
278
|
-
const entry = findOpenTerminal(params?.terminal_id);
|
|
279
|
-
if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see terminal_list)`);
|
|
280
|
-
const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
|
|
281
|
-
if (!v.ok) return errText(v.error);
|
|
197
|
+
async function executeTerminalRead(params) {
|
|
198
|
+
let paneId: string | null = null;
|
|
199
|
+
let entry: TerminalEntry | null = null;
|
|
200
|
+
if (typeof params?.terminal_id === 'string') {
|
|
201
|
+
entry = findOpenTerminal(params.terminal_id);
|
|
202
|
+
if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
|
|
203
|
+
paneId = entry.paneId;
|
|
204
|
+
} else if (typeof params?.pane_id === 'string') {
|
|
205
|
+
// T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
|
|
282
206
|
try {
|
|
283
|
-
await client.
|
|
207
|
+
const panes = await client.listPanes();
|
|
208
|
+
const target = panes.find((p) => p.paneId === params.pane_id);
|
|
209
|
+
const ownTab = target?.tabId && target.tabId === env?.tabId;
|
|
210
|
+
const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
|
|
211
|
+
if (!target || (!ownTab && !ownTerminal)) {
|
|
212
|
+
return errText('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
|
|
213
|
+
}
|
|
214
|
+
paneId = params.pane_id;
|
|
284
215
|
} catch (e) {
|
|
285
|
-
return errText(`
|
|
216
|
+
return errText(`pane lookup failed: ${(e as Error).message}`);
|
|
286
217
|
}
|
|
218
|
+
} else {
|
|
219
|
+
return errText('provide terminal_id (or pane_id for a direct own-tab read)');
|
|
220
|
+
}
|
|
221
|
+
const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
|
|
222
|
+
? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
|
|
223
|
+
let read: { text: string; revision: number; truncated: boolean };
|
|
224
|
+
try {
|
|
225
|
+
read = await client.readPane(paneId, { stripAnsi: false });
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return errText(`read failed (pane may be closed): ${(e as Error).message}`);
|
|
228
|
+
}
|
|
229
|
+
// T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
|
|
230
|
+
const tui = detectFullscreenTUI(read.text);
|
|
231
|
+
if (tui.detected) {
|
|
232
|
+
const head = stripAnsi(read.text).slice(0, 200);
|
|
233
|
+
const text = [
|
|
234
|
+
'FULLSCREEN_TUI_DETECTED: this terminal is running a fullscreen program (e.g. vim/less/top); its screen output is not readable here.',
|
|
235
|
+
'Suggestions:',
|
|
236
|
+
'1. Run such programs in a native herdr pane where the human can interact directly.',
|
|
237
|
+
'2. Use non-interactive alternatives (cat/grep instead of less; edit/write tools instead of vim).',
|
|
238
|
+
`Raw head (first 200 chars): ${head}`,
|
|
239
|
+
].join('\n');
|
|
240
|
+
return { content: [{ type: 'text', text }], details: { error: 'FULLSCREEN_TUI_DETECTED' } };
|
|
241
|
+
}
|
|
242
|
+
const prev: ReadCursor | null = entry
|
|
243
|
+
? (entry.readRevision != null
|
|
244
|
+
? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
|
|
245
|
+
: null)
|
|
246
|
+
: null;
|
|
247
|
+
const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
|
|
248
|
+
if (entry) {
|
|
249
|
+
entry.readRevision = inc.cursor.revision;
|
|
250
|
+
entry.readLen = inc.cursor.len;
|
|
251
|
+
entry.readTail = inc.cursor.tail;
|
|
252
|
+
entry.readEoTail = inc.cursor.eoTail;
|
|
287
253
|
touchTerminal(entry);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
254
|
+
}
|
|
255
|
+
if (inc.mode === 'none') {
|
|
256
|
+
return { content: [{ type: 'text', text: 'no new output' }], details: { mode: 'none', revision: read.revision } };
|
|
257
|
+
}
|
|
258
|
+
const output = stripAnsi(inc.text);
|
|
259
|
+
const text = output.length ? output : '(empty)';
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: 'text', text }],
|
|
262
|
+
details: { mode: inc.mode, revision: read.revision, hard_capped: inc.hardCapped, source_revision_truncated: read.truncated },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
291
265
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (!entry) return errText(`unknown terminal "${String(id)}" (see terminal_list)`);
|
|
306
|
-
if (entry.status === 'open') {
|
|
307
|
-
try {
|
|
308
|
-
await client.closePane(entry.paneId);
|
|
309
|
-
} catch {
|
|
310
|
-
/* The pane may have been closed by a person or with its tab; record the terminal as closed anyway. */
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
|
|
314
|
-
persistTerminals();
|
|
315
|
-
return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
|
|
316
|
-
},
|
|
317
|
-
});
|
|
266
|
+
async function executeTerminalSignal(params) {
|
|
267
|
+
const entry = findOpenTerminal(params?.terminal_id);
|
|
268
|
+
if (!entry) return errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
269
|
+
const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
|
|
270
|
+
if (!v.ok) return errText(v.error);
|
|
271
|
+
try {
|
|
272
|
+
await client.sendPaneKeys(entry.paneId, [v.key]);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return errText(`signal failed (pane may be closed): ${(e as Error).message}`);
|
|
275
|
+
}
|
|
276
|
+
touchTerminal(entry);
|
|
277
|
+
return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
|
|
278
|
+
}
|
|
318
279
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
'Entries whose pane is gone (human closed it, restart) are marked closed — terminal sessions do not survive pane closure or restarts; persist state to files instead of relying on terminal reuse.',
|
|
325
|
-
].join(' '),
|
|
326
|
-
parameters: Type.Object({}),
|
|
327
|
-
async execute() {
|
|
328
|
-
let livePaneIds: string[] = [];
|
|
280
|
+
async function executeTerminalClose(params) {
|
|
281
|
+
const id = params?.terminal_id;
|
|
282
|
+
const entry = terminals.find((t) => t.terminalId === id);
|
|
283
|
+
if (!entry) return errText(`unknown terminal "${String(id)}" (see action list)`);
|
|
284
|
+
if (entry.status === 'open') {
|
|
329
285
|
try {
|
|
330
|
-
|
|
286
|
+
await client.closePane(entry.paneId);
|
|
331
287
|
} catch {
|
|
332
|
-
/*
|
|
333
|
-
}
|
|
334
|
-
const s = summarizeSessions(terminals, livePaneIds);
|
|
335
|
-
// T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
|
|
336
|
-
if (s.stalePaneIds.length > 0) {
|
|
337
|
-
terminals = terminals.map((t) =>
|
|
338
|
-
s.stalePaneIds.includes(t.paneId)
|
|
339
|
-
? { ...t, status: 'closed' as const, closedAt: t.closedAt ?? Date.now() }
|
|
340
|
-
: t,
|
|
341
|
-
);
|
|
342
|
-
persistTerminals();
|
|
288
|
+
/* The pane may have been closed by a person or with its tab; record the terminal as closed anyway. */
|
|
343
289
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
290
|
+
}
|
|
291
|
+
terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
|
|
292
|
+
persistTerminals();
|
|
293
|
+
return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function executeTerminalList() {
|
|
297
|
+
let livePaneIds: string[] = [];
|
|
298
|
+
try {
|
|
299
|
+
livePaneIds = (await client.listPanes()).map((p) => p.paneId);
|
|
300
|
+
} catch {
|
|
301
|
+
/* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
|
|
302
|
+
}
|
|
303
|
+
const s = summarizeSessions(terminals, livePaneIds);
|
|
304
|
+
// T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
|
|
305
|
+
if (s.stalePaneIds.length > 0) {
|
|
306
|
+
terminals = terminals.map((t) =>
|
|
307
|
+
s.stalePaneIds.includes(t.paneId)
|
|
308
|
+
? { ...t, status: 'closed' as const, closedAt: t.closedAt ?? Date.now() }
|
|
309
|
+
: t,
|
|
349
310
|
);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return { content: [{ type: 'text', text:
|
|
354
|
-
}
|
|
355
|
-
|
|
311
|
+
persistTerminals();
|
|
312
|
+
}
|
|
313
|
+
if (s.terminals.length === 0) {
|
|
314
|
+
return { content: [{ type: 'text', text: 'no terminals (open one with action open)' }], details: { terminals: [] } };
|
|
315
|
+
}
|
|
316
|
+
const lines = s.terminals.map((t) =>
|
|
317
|
+
`- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}`,
|
|
318
|
+
);
|
|
319
|
+
const note = s.stalePaneIds.length > 0
|
|
320
|
+
? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
|
|
321
|
+
: '';
|
|
322
|
+
return { content: [{ type: 'text', text: lines.join('\n') + note }], details: { terminals: s.terminals } };
|
|
323
|
+
}
|
|
356
324
|
}
|
package/src/core/todo.ts
CHANGED
|
@@ -13,12 +13,11 @@ 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
15
|
import { makeProgressUpdate } from '../subagent-core.ts';
|
|
16
|
-
import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem
|
|
16
|
+
import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
|
|
17
17
|
import { formatAge, isArchived } from '../stale-core.ts';
|
|
18
18
|
import {
|
|
19
19
|
TODO_EDIT_CUSTOM_TYPE,
|
|
20
20
|
completionTransitions,
|
|
21
|
-
countTodos,
|
|
22
21
|
fuzzyFind,
|
|
23
22
|
listsEqual,
|
|
24
23
|
makeSnapshot,
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
revertedCompleted,
|
|
27
26
|
validateTodos,
|
|
28
27
|
} from '../todo-core.ts';
|
|
28
|
+
import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
|
|
29
29
|
|
|
30
30
|
export interface TodoUiSlot {
|
|
31
31
|
/** Lets index lifecycle events render through the currently mounted plugin. */
|
|
@@ -52,37 +52,9 @@ const TOOL_DESCRIPTION = [
|
|
|
52
52
|
'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
|
|
53
53
|
].join(' ');
|
|
54
54
|
|
|
55
|
-
const TODO_MARKS: Record<TodoStatus, string> = {
|
|
56
|
-
pending: '○',
|
|
57
|
-
in_progress: '▶',
|
|
58
|
-
completed: '✓',
|
|
59
|
-
blocked: '■',
|
|
60
|
-
abandoned: '✗',
|
|
61
|
-
};
|
|
62
|
-
|
|
63
55
|
/** Stay within pi interactive mode's ten-line widget cap instead of relying on head truncation. */
|
|
64
56
|
export const WIDGET_MAX_LINES = 10;
|
|
65
57
|
|
|
66
|
-
/** Share phase rendering between the widget and /todos so both views order entries identically. */
|
|
67
|
-
function renderGroups(items: readonly TodoItem[]): string[] {
|
|
68
|
-
const groups = new Map<string, TodoItem[]>();
|
|
69
|
-
for (const it of items) {
|
|
70
|
-
const key = it.phase ?? '';
|
|
71
|
-
const arr = groups.get(key) ?? [];
|
|
72
|
-
arr.push(it);
|
|
73
|
-
groups.set(key, arr);
|
|
74
|
-
}
|
|
75
|
-
const lines: string[] = [];
|
|
76
|
-
for (const [phase, list] of groups) {
|
|
77
|
-
if (phase) lines.push(` [${phase}]`);
|
|
78
|
-
for (const it of list) {
|
|
79
|
-
const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
|
|
80
|
-
lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return lines;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
58
|
/**
|
|
87
59
|
* Keep the active task visible within WIDGET_MAX_LINES instead of truncating to a fixed head or
|
|
88
60
|
* tail. The window anchors on the first in-progress item, then the last open item, then recent
|
|
@@ -91,21 +63,23 @@ function renderGroups(items: readonly TodoItem[]): string[] {
|
|
|
91
63
|
*/
|
|
92
64
|
export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?: number | null }): string[] {
|
|
93
65
|
if (items.length === 0) return [];
|
|
94
|
-
const c = countTodos(items);
|
|
95
66
|
// Collapse archived plans to two lines so stale work cannot monopolize the widget.
|
|
96
67
|
if (opts?.archivedAgeMs != null) {
|
|
97
68
|
return [
|
|
98
|
-
|
|
69
|
+
`${formatTodoSummary(items)} · archived ${formatAge(opts.archivedAgeMs)}`,
|
|
99
70
|
' archived — /todos 全量',
|
|
100
71
|
];
|
|
101
72
|
}
|
|
102
73
|
|
|
103
74
|
// Reserve summary and overflow lines, then shrink around the anchor because phase headers also consume the budget.
|
|
104
75
|
const renderBudget = WIDGET_MAX_LINES - 1 - 1;
|
|
105
|
-
const [start, end] =
|
|
76
|
+
const [start, end] = anchorTodoRange(
|
|
77
|
+
items,
|
|
78
|
+
(s, e) => renderTodoGroups(items.slice(s, e)).length <= renderBudget,
|
|
79
|
+
);
|
|
106
80
|
const kept = items.slice(start, end);
|
|
107
81
|
const hidden = items.filter((_, i) => i < start || i >= end);
|
|
108
|
-
const lines = [
|
|
82
|
+
const lines = [formatTodoSummary(items), ...renderTodoGroups(kept)];
|
|
109
83
|
if (hidden.length > 0) {
|
|
110
84
|
const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
|
|
111
85
|
lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
|
|
@@ -113,44 +87,6 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
|
|
|
113
87
|
return lines;
|
|
114
88
|
}
|
|
115
89
|
|
|
116
|
-
/**
|
|
117
|
-
* Expand around the active anchor within the rendered-line budget, favoring upcoming work while
|
|
118
|
-
* guaranteeing that the anchor remains visible.
|
|
119
|
-
*/
|
|
120
|
-
function anchorRange(items: readonly TodoItem[], renderBudget: number): [number, number] {
|
|
121
|
-
if (renderGroups(items).length <= renderBudget) return [0, items.length];
|
|
122
|
-
const anchor = items.findIndex((it) => it.status === 'in_progress');
|
|
123
|
-
const anchorIdx = anchor >= 0
|
|
124
|
-
? anchor
|
|
125
|
-
: items.reduce((acc, it, i) => (it.status !== 'completed' ? i : acc), -1);
|
|
126
|
-
if (anchorIdx < 0) {
|
|
127
|
-
// Completed plans keep the most recent tail visible within the line budget.
|
|
128
|
-
let end = items.length;
|
|
129
|
-
while (renderGroups(items.slice(0, end)).length > renderBudget && end > 1) end -= 1;
|
|
130
|
-
return [0, end];
|
|
131
|
-
}
|
|
132
|
-
let start = anchorIdx;
|
|
133
|
-
let end = anchorIdx + 1;
|
|
134
|
-
const fits = () => renderGroups(items.slice(start, end)).length <= renderBudget;
|
|
135
|
-
let growTail = true;
|
|
136
|
-
while ((end < items.length || start > 0) && (end - start) < items.length) {
|
|
137
|
-
if (growTail && end < items.length) {
|
|
138
|
-
end += 1;
|
|
139
|
-
if (!fits()) { end -= 1; break; }
|
|
140
|
-
} else if (start > 0) {
|
|
141
|
-
start -= 1;
|
|
142
|
-
if (!fits()) { start += 1; break; }
|
|
143
|
-
} else if (end < items.length) {
|
|
144
|
-
end += 1;
|
|
145
|
-
if (!fits()) { end -= 1; break; }
|
|
146
|
-
} else {
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
growTail = !growTail; // Alternate sides so both context and upcoming work remain visible.
|
|
150
|
-
}
|
|
151
|
-
return [start, end];
|
|
152
|
-
}
|
|
153
|
-
|
|
154
90
|
export default function todoPlugin(ctx: Context): void {
|
|
155
91
|
const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
|
|
156
92
|
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state } =
|
|
@@ -372,14 +308,13 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
372
308
|
return;
|
|
373
309
|
}
|
|
374
310
|
// /todos is the unbounded view because the widget intentionally prioritizes active context.
|
|
375
|
-
const body =
|
|
311
|
+
const body = renderTodoGroups(todos.items);
|
|
376
312
|
if (body.length === 0) {
|
|
377
313
|
ui?.notify?.('todo list is empty', 'info');
|
|
378
314
|
return;
|
|
379
315
|
}
|
|
380
|
-
const c = countTodos(todos.items);
|
|
381
316
|
const age = archivedAgeMs();
|
|
382
|
-
const head =
|
|
317
|
+
const head = formatTodoSummary(todos.items)
|
|
383
318
|
+ (age != null ? ` · archived ${formatAge(age)}(不再注入)` : '');
|
|
384
319
|
ui?.notify?.([head, ...body].join('\n'), 'info');
|
|
385
320
|
},
|