pi-pier 0.3.1 → 0.4.1
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 +8 -5
- package/package.json +3 -3
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +3 -2
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +40 -15
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +1 -4
- package/src/efficiency-store.ts +1 -1
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +126 -31
- package/src/history-store.ts +2 -0
- package/src/index-gates.ts +6 -12
- package/src/index-master.ts +12 -16
- package/src/index-pipe.ts +14 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +298 -48
- package/src/jev-core.ts +1 -3
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +17 -0
- package/src/plugins/subagent.ts +68 -30
- package/src/plugins/terminal.ts +34 -15
- package/src/plugins/todo.ts +20 -42
- package/src/reducer-core.ts +3 -5
- package/src/renderers.ts +37 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +3 -3
- package/src/settle-wake-core.ts +9 -1
- package/src/subagent-core.ts +170 -2
- package/src/subagent-poller.ts +17 -1
- package/src/subagent-spawn.ts +51 -4
- package/src/terminal-core.ts +68 -34
- package/src/todo-read-hook.ts +23 -0
- package/src/tree-view.ts +162 -0
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
package/src/plugins/subagent.ts
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
import { Context } from '@deepseek-ai/cordis';
|
|
8
8
|
import { Type } from 'typebox';
|
|
9
9
|
import { basename, dirname, join } from 'node:path';
|
|
10
|
-
import
|
|
10
|
+
import { cordisGet } from '../cordis-services.ts';
|
|
11
11
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
12
12
|
import {
|
|
13
13
|
Semaphore,
|
|
14
|
+
SUBAGENT_OUTPUT_SCHEMA,
|
|
14
15
|
agoText,
|
|
15
|
-
|
|
16
|
+
attachSubagentStructuredContent,
|
|
16
17
|
buildAliveNotice,
|
|
17
18
|
idParam,
|
|
18
19
|
flattenToolParams,
|
|
@@ -20,7 +21,9 @@ import {
|
|
|
20
21
|
newestPerTaskId,
|
|
21
22
|
rekeySub,
|
|
22
23
|
resolveTaskIdPrefix,
|
|
24
|
+
taskIdResolutionError,
|
|
23
25
|
tabNameForTask,
|
|
26
|
+
subRef,
|
|
24
27
|
type AliveProbe,
|
|
25
28
|
type SubEntry,
|
|
26
29
|
type SubagentPort,
|
|
@@ -41,8 +44,9 @@ import { toolError, ToolError } from '../tool-error.ts';
|
|
|
41
44
|
import { pipeNameFor, pipeRequestTo, type PipeRequest, type PipeResponse } from '../pipe-channel.ts';
|
|
42
45
|
import type { TerminalStateSlot } from './terminal.ts';
|
|
43
46
|
import { bareSessionId, resolveSessionFileValue } from '../session-tail.ts';
|
|
44
|
-
import {
|
|
47
|
+
import { DELEGATION_ENV, type DelegationContext } from '../delegation-core.ts';
|
|
45
48
|
import type { JevRuntime } from '../jev-client.ts';
|
|
49
|
+
import { createGitIo, createSpawnAction, createSpawner } from '../subagent-spawn.ts';
|
|
46
50
|
import { createPoller } from '../subagent-poller.ts';
|
|
47
51
|
import { createGcController, createSubagentRegistry } from '../subagent-gc.ts';
|
|
48
52
|
import { createSessionIo, executeSubagentOutput, type SubagentOutputCursor } from '../subagent-session.ts';
|
|
@@ -106,8 +110,8 @@ function defaultAgentSessionsDir(): string {
|
|
|
106
110
|
}
|
|
107
111
|
|
|
108
112
|
export default function subagentPlugin(ctx: Context): void {
|
|
109
|
-
const surface = ctx
|
|
110
|
-
const d = ctx
|
|
113
|
+
const surface = cordisGet(ctx, 'pi-herdr.surface');
|
|
114
|
+
const d = cordisGet(ctx, 'pi-herdr.subagent-deps');
|
|
111
115
|
const { client, env, sessionRoot, port, terminalState } = d;
|
|
112
116
|
const pi = surface.raw as {
|
|
113
117
|
appendEntry?: (customType: string, data: unknown) => void;
|
|
@@ -141,6 +145,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
141
145
|
taskId: e.taskId,
|
|
142
146
|
kind: e.kind,
|
|
143
147
|
paneId: e.paneId,
|
|
148
|
+
...(e.slug ? { slug: e.slug } : {}),
|
|
144
149
|
tabId: e.tabId,
|
|
145
150
|
tabName: e.tabName,
|
|
146
151
|
workspaceId: env?.workspaceId ?? '',
|
|
@@ -187,7 +192,19 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
187
192
|
listRunningSubs() {
|
|
188
193
|
return [...subs.values()]
|
|
189
194
|
.filter((s) => s.background && s.status === 'running')
|
|
190
|
-
.map((s) => ({ paneId: s.paneId, description: s.description }));
|
|
195
|
+
.map((s) => ({ paneId: s.paneId, slug: s.slug, description: s.description, kind: s.kind }));
|
|
196
|
+
},
|
|
197
|
+
listRows() {
|
|
198
|
+
return [...subs.values()]
|
|
199
|
+
.filter((s) => s.status !== 'closed')
|
|
200
|
+
.map((s) => ({
|
|
201
|
+
paneId: s.paneId,
|
|
202
|
+
slug: s.slug,
|
|
203
|
+
description: s.description,
|
|
204
|
+
kind: s.kind,
|
|
205
|
+
status: s.status,
|
|
206
|
+
settledAt: s.consumedAt ?? null,
|
|
207
|
+
}));
|
|
191
208
|
},
|
|
192
209
|
async settleStatLine(paneId) {
|
|
193
210
|
const entry = subs.get(paneId);
|
|
@@ -331,7 +348,18 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
331
348
|
const spawned = await spawnPaneInTaskTab(
|
|
332
349
|
{ desiredTab: entry.tabName || latest.tabName || null, description: entry.description },
|
|
333
350
|
entry.cwd,
|
|
334
|
-
{
|
|
351
|
+
{
|
|
352
|
+
PI_HERDR_SUBAGENT: '1',
|
|
353
|
+
[DELEGATION_ENV]: JSON.stringify({
|
|
354
|
+
parentPaneId: env?.paneId ?? '',
|
|
355
|
+
parentSessionId: bareSessionId(d.getSessionId()),
|
|
356
|
+
// Phase 2: report_progress needs the master's pipe; kind 'task' maps to the composed default.
|
|
357
|
+
parentPipe: pipeNameFor(process.cwd(), env?.paneId ?? ''),
|
|
358
|
+
taskId: entry.taskId,
|
|
359
|
+
description: entry.description,
|
|
360
|
+
role: entry.kind === 'task' ? 'worker-default' : entry.kind,
|
|
361
|
+
} satisfies DelegationContext),
|
|
362
|
+
},
|
|
335
363
|
launchLine(resumeFile, null, approve),
|
|
336
364
|
);
|
|
337
365
|
const ready = await waitSubReady(entry.cwd, spawned.paneId);
|
|
@@ -358,6 +386,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
358
386
|
taskId: h.taskId,
|
|
359
387
|
kind: h.kind,
|
|
360
388
|
paneId: h.paneId,
|
|
389
|
+
...(h.slug ? { slug: h.slug } : {}),
|
|
361
390
|
tabId: h.tabId,
|
|
362
391
|
tabName: h.tabName ?? '',
|
|
363
392
|
cwd: h.cwd,
|
|
@@ -377,12 +406,23 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
377
406
|
if (!rawId) return { error: 'Error: unknown subagent id "" (see action list)' };
|
|
378
407
|
const direct = subs.get(rawId);
|
|
379
408
|
if (direct) return { entry: direct };
|
|
409
|
+
// Phase 2 slug reference: exact match against live rows (paneId stays first for back-compat).
|
|
410
|
+
// `slug@paneId` accepts both parts; a revived row changed its paneId, so the slug part wins then.
|
|
411
|
+
const at = rawId.indexOf('@');
|
|
412
|
+
const slugPart = at > 0 ? rawId.slice(0, at) : rawId;
|
|
413
|
+
const panePart = at > 0 ? rawId.slice(at + 1) : null;
|
|
414
|
+
const combined = panePart !== null
|
|
415
|
+
? (subs.get(panePart) ?? [...subs.values()].find((s) => s.slug === slugPart && s.paneId === panePart) ?? null)
|
|
416
|
+
: null;
|
|
417
|
+
if (combined) return { entry: combined };
|
|
418
|
+
const bySlug = [...subs.values()].find((s) => s.slug === slugPart);
|
|
419
|
+
if (bySlug) return { entry: bySlug };
|
|
380
420
|
|
|
381
421
|
const byTaskId = newestPerTaskId(subs.values());
|
|
382
422
|
const res = resolveTaskIdPrefix(rawId, byTaskId.keys());
|
|
383
423
|
if (res.kind === 'resolved') return { entry: byTaskId.get(res.taskId)! };
|
|
384
|
-
|
|
385
|
-
if (
|
|
424
|
+
const resError = taskIdResolutionError(res, 'subagent id', rawId);
|
|
425
|
+
if (resError) return { error: resError };
|
|
386
426
|
|
|
387
427
|
if (cwd) {
|
|
388
428
|
const hist = readHistory(histFile(cwd));
|
|
@@ -395,7 +435,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
395
435
|
return { entry };
|
|
396
436
|
}
|
|
397
437
|
}
|
|
398
|
-
|
|
438
|
+
const histError = taskIdResolutionError(histRes, 'subagent id', rawId);
|
|
439
|
+
if (histError) return { error: histError };
|
|
399
440
|
}
|
|
400
441
|
return { error: `Error: unknown subagent id "${rawId}" (see action list)` };
|
|
401
442
|
}
|
|
@@ -415,16 +456,14 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
415
456
|
persistSubs();
|
|
416
457
|
}
|
|
417
458
|
|
|
418
|
-
async function executeSubagentResume(params: ToolParams
|
|
459
|
+
async function executeSubagentResume(params: ToolParams) {
|
|
419
460
|
if (!client.available) return toolError('requires a herdr-managed pane.');
|
|
420
461
|
const rawTaskId = idParam(params, 'taskId', 'agentId');
|
|
421
462
|
if (!rawTaskId) return toolError('missing taskId for resume (see action list or delegation ledger).');
|
|
422
463
|
const running = newestPerTaskId([...subs.values()].filter((s) => s.background && s.status === 'running'));
|
|
423
464
|
const live = resolveTaskIdPrefix(rawTaskId, running.keys());
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
if (live.kind === 'ambiguous') return toolError(ambiguousIdError('task id', rawTaskId, live.candidates));
|
|
465
|
+
const liveError = taskIdResolutionError(live, 'task id', rawTaskId);
|
|
466
|
+
if (liveError) return toolError(liveError);
|
|
428
467
|
if (live.kind === 'resolved') {
|
|
429
468
|
const entry = running.get(live.taskId)!;
|
|
430
469
|
return {
|
|
@@ -434,12 +473,9 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
434
473
|
}
|
|
435
474
|
const history = readHistoryTree(dirname(defaultAgentSessionsDir()));
|
|
436
475
|
const resolution = resolveTaskIdPrefix(rawTaskId, history.map((e) => e.taskId));
|
|
437
|
-
if (resolution.kind
|
|
438
|
-
return toolError(
|
|
439
|
-
|
|
440
|
-
if (resolution.kind === 'ambiguous') return toolError(ambiguousIdError('task id', rawTaskId, resolution.candidates));
|
|
441
|
-
if (resolution.kind === 'not_found') {
|
|
442
|
-
return toolError(`Error: no history for task "${rawTaskId}" in this workspace or its spawned checkouts.`);
|
|
476
|
+
if (resolution.kind !== 'resolved') {
|
|
477
|
+
return toolError(taskIdResolutionError(resolution, 'task id', rawTaskId)
|
|
478
|
+
?? `Error: no history for task "${rawTaskId}" in this workspace or its spawned checkouts.`);
|
|
443
479
|
}
|
|
444
480
|
const taskId = resolution.taskId;
|
|
445
481
|
const latest = latestGeneration(history, taskId);
|
|
@@ -477,7 +513,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
477
513
|
? 'reused existing pane with same session; pi still running there'
|
|
478
514
|
: entry.sessionFile ? 'session restored' : 'session file missing; fresh conversation';
|
|
479
515
|
return {
|
|
480
|
-
content: [{ type: 'text', text: `resumed subagent ${entry
|
|
516
|
+
content: [{ type: 'text', text: `resumed subagent ${subRef(entry)} from task ${taskId} (${how}).` }],
|
|
481
517
|
details: { paneId: entry.paneId, taskId },
|
|
482
518
|
};
|
|
483
519
|
} catch (err) {
|
|
@@ -548,7 +584,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
548
584
|
persistSubs();
|
|
549
585
|
throw inner;
|
|
550
586
|
}
|
|
551
|
-
return { content: [{ type: 'text', text: `Message sent to subagent ${entry
|
|
587
|
+
return { content: [{ type: 'text', text: `Message sent to subagent ${subRef(entry)}.` }], details: { paneId: entry.paneId } };
|
|
552
588
|
},
|
|
553
589
|
});
|
|
554
590
|
}
|
|
@@ -556,7 +592,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
556
592
|
async function executeSubagentInterrupt(params: ToolParams, toolCtx: unknown) {
|
|
557
593
|
const { entry } = targetOf(params, toolCtx);
|
|
558
594
|
if (entry.status === 'closed') {
|
|
559
|
-
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry
|
|
595
|
+
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${subRef(entry)} (already idle/closed; no-op).` }], details: {} };
|
|
560
596
|
}
|
|
561
597
|
return pipeAction(entry, {
|
|
562
598
|
ready: false,
|
|
@@ -565,7 +601,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
565
601
|
// No settlement notice for an interrupted turn: the requester already knows.
|
|
566
602
|
const lastId = lastRequestIdByPane.get(entry.paneId);
|
|
567
603
|
if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
|
|
568
|
-
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry
|
|
604
|
+
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${subRef(entry)} (fire-and-return).` }], details: { paneId: entry.paneId } };
|
|
569
605
|
},
|
|
570
606
|
});
|
|
571
607
|
}
|
|
@@ -608,7 +644,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
608
644
|
continue;
|
|
609
645
|
}
|
|
610
646
|
if (sub.status === 'consumed' || sub.status === 'settled') {
|
|
611
|
-
lines.push(`${sub
|
|
647
|
+
lines.push(`${subRef(sub)} [idle] (${sub.kind}, settled; action send revives)${tabTag}${wtTag} ${sub.description}`);
|
|
612
648
|
continue;
|
|
613
649
|
}
|
|
614
650
|
const state = 'running';
|
|
@@ -624,7 +660,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
624
660
|
const cwdTag = probe?.foregroundCwd && probe.foregroundCwd !== sub.cwd
|
|
625
661
|
? ` [cwd: ${basename(probe.foregroundCwd) || probe.foregroundCwd}]`
|
|
626
662
|
: '';
|
|
627
|
-
lines.push(`${sub
|
|
663
|
+
lines.push(`${subRef(sub)} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag}${cwdTag} ${sub.description}`);
|
|
628
664
|
}
|
|
629
665
|
return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
|
|
630
666
|
}
|
|
@@ -643,6 +679,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
643
679
|
session,
|
|
644
680
|
spawn,
|
|
645
681
|
poller,
|
|
682
|
+
getSessionId: d.getSessionId,
|
|
646
683
|
...(d.logRouting ? { logRouting: d.logRouting } : {}),
|
|
647
684
|
});
|
|
648
685
|
|
|
@@ -659,7 +696,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
659
696
|
|
|
660
697
|
const ACTIONS: Record<string, (params: ToolParams, toolCtx: unknown, onUpdate?: (u: unknown) => void) => Promise<unknown>> = {
|
|
661
698
|
spawn: (params, toolCtx, onUpdate) => executeSubagentSpawn(params, toolCtx, onUpdate),
|
|
662
|
-
resume: (params
|
|
699
|
+
resume: (params) => executeSubagentResume(params),
|
|
663
700
|
list: () => executeSubagentList(),
|
|
664
701
|
send: (params, toolCtx) => executeSubagentSend(params, toolCtx),
|
|
665
702
|
interrupt: (params, toolCtx) => executeSubagentInterrupt(params, toolCtx),
|
|
@@ -702,10 +739,11 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
702
739
|
tab: Type.Optional(Type.String({ description: '[spawn] Name of a task tab to place the subagent into (join if exists, otherwise create). Default placement groups by git worktree.' })),
|
|
703
740
|
allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Additional tools for role composition (union with role baseline)' })),
|
|
704
741
|
taskId: Type.Optional(Type.String({ description: '[resume] The task id to revive from the delegation ledger' })),
|
|
705
|
-
agentId: Type.Optional(Type.String({ description: '[send|interrupt|output|role] The subagent
|
|
742
|
+
agentId: Type.Optional(Type.String({ description: '[send|interrupt|output|role] The subagent reference: task slug (e.g. "fix-auth"), herdr pane id, or "slug@paneId" as printed by spawn/list' })),
|
|
706
743
|
message: Type.Optional(Type.String({ description: '[send] The follow-up message' })),
|
|
707
744
|
max_chars: Type.Optional(Type.Integer({ description: '[output] Maximum characters of output delta to return (default 6000, 100-16000)' })),
|
|
708
745
|
}),
|
|
746
|
+
outputSchema: SUBAGENT_OUTPUT_SCHEMA,
|
|
709
747
|
// B2: `action` is optional in the schema; normalize it so the validated arguments and the
|
|
710
748
|
// code path never disagree and `spawn` is explicit in logs.
|
|
711
749
|
prepareArguments: (args: unknown) => {
|
|
@@ -722,7 +760,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
722
760
|
if (!handler) {
|
|
723
761
|
return toolError(`Error: unknown action "${action}" (valid: ${Object.keys(ACTIONS).join(', ')})`);
|
|
724
762
|
}
|
|
725
|
-
return handler(flat, toolCtx, onUpdate);
|
|
763
|
+
return attachSubagentStructuredContent(action, await handler(flat, toolCtx, onUpdate));
|
|
726
764
|
},
|
|
727
765
|
});
|
|
728
766
|
}
|
package/src/plugins/terminal.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Context } from '@deepseek-ai/cordis';
|
|
8
8
|
import { Type } from 'typebox';
|
|
9
|
-
import
|
|
9
|
+
import { cordisGet } from '../cordis-services.ts';
|
|
10
10
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
11
11
|
import { herdrUnavailableHint } from '../herdr-client.ts';
|
|
12
12
|
import { toolError } from '../tool-error.ts'
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
detectFullscreenTUI,
|
|
24
24
|
foldTerminalsRegistry,
|
|
25
25
|
makeTerminalsRegistry,
|
|
26
|
-
|
|
26
|
+
planTerminalReminder,
|
|
27
27
|
planShellInit,
|
|
28
28
|
promptStrategyFor,
|
|
29
29
|
registerTerminal,
|
|
@@ -52,15 +52,15 @@ export interface TerminalStateSlot {
|
|
|
52
52
|
activePaneIds: () => Set<string>;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
interface TerminalDeps {
|
|
55
|
+
export interface TerminalDeps {
|
|
56
56
|
client: HerdrClientLike;
|
|
57
57
|
env: { paneId: string; tabId: string } | null;
|
|
58
58
|
state: TerminalStateSlot;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
export default function terminalPlugin(ctx: Context): void {
|
|
62
|
-
const surface = ctx
|
|
63
|
-
const { client, env, state } = ctx
|
|
62
|
+
const surface = cordisGet(ctx, 'pi-herdr.surface');
|
|
63
|
+
const { client, env, state } = cordisGet(ctx, 'pi-herdr.terminal-deps');
|
|
64
64
|
const pi = surface.raw as {
|
|
65
65
|
appendEntry?: (customType: string, data: unknown) => void;
|
|
66
66
|
};
|
|
@@ -129,17 +129,20 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
129
129
|
persistTerminals();
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
// Turn-end nudge: remind the model to close terminals
|
|
133
|
-
// shape as the todo stop reminder: grace window, cancel on agent_start,
|
|
132
|
+
// Turn-end nudge: remind the model to close terminals it just used, then once more after the idle
|
|
133
|
+
// threshold. Same delivery shape as the todo stop reminder: grace window, cancel on agent_start,
|
|
134
|
+
// capped per terminal.
|
|
134
135
|
const piSend = surface.raw as {
|
|
135
136
|
sendMessage?: (
|
|
136
137
|
message: { customType: string; content: string; display?: boolean },
|
|
137
138
|
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
138
139
|
) => void;
|
|
139
140
|
};
|
|
140
|
-
|
|
141
|
+
/** Start of the current agent run; terminals touched since then count as "used this turn". */
|
|
142
|
+
let turnStartedAt: number | null = null;
|
|
141
143
|
let idleReminderTimer: NodeJS.Timeout | null = null;
|
|
142
144
|
scoped.on('agent_start', () => {
|
|
145
|
+
turnStartedAt = Date.now();
|
|
143
146
|
if (idleReminderTimer !== null) {
|
|
144
147
|
clearTimeout(idleReminderTimer);
|
|
145
148
|
idleReminderTimer = null;
|
|
@@ -150,19 +153,33 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
150
153
|
clearTimeout(idleReminderTimer);
|
|
151
154
|
idleReminderTimer = null;
|
|
152
155
|
}
|
|
153
|
-
const
|
|
154
|
-
|
|
156
|
+
const open = await Promise.all(
|
|
157
|
+
terminals
|
|
158
|
+
.filter((t) => t.status === 'open')
|
|
159
|
+
.map(async (t) => {
|
|
160
|
+
// A pane running a foreground job (dev server, REPL, load test) is in use even when the
|
|
161
|
+
// last tool interaction is old: nudge only after it is back at a shell prompt.
|
|
162
|
+
const info = await client.processInfo(t.paneId).catch(() => null);
|
|
163
|
+
const busy = info?.shellPid != null
|
|
164
|
+
&& info.foreground.some((p) => p.pid != null && p.pid !== info.shellPid);
|
|
165
|
+
return { ...t, busy };
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
const plan = planTerminalReminder({
|
|
169
|
+
open,
|
|
155
170
|
now: Date.now(),
|
|
156
|
-
|
|
171
|
+
turnStartedAt,
|
|
157
172
|
});
|
|
173
|
+
turnStartedAt = null;
|
|
158
174
|
if (!plan.due || plan.content == null) return;
|
|
159
|
-
// Goodbye-loop guard:
|
|
160
|
-
// queued followUp WITHOUT triggerTurn — a reminder must never wake the agent
|
|
175
|
+
// Goodbye-loop guard: bump each covered terminal's nudge count NOW (persisted), and deliver the
|
|
176
|
+
// notice as a queued followUp WITHOUT triggerTurn — a reminder must never wake the agent.
|
|
161
177
|
const now = Date.now();
|
|
162
178
|
const ids = new Set(plan.ids);
|
|
163
|
-
terminals = terminals.map((t) => (ids.has(t.terminalId)
|
|
179
|
+
terminals = terminals.map((t) => (ids.has(t.terminalId)
|
|
180
|
+
? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
|
|
181
|
+
: t));
|
|
164
182
|
persistTerminals();
|
|
165
|
-
idleReminders += 1;
|
|
166
183
|
const content = plan.content;
|
|
167
184
|
idleReminderTimer = setTimeout(() => {
|
|
168
185
|
idleReminderTimer = null;
|
|
@@ -345,6 +362,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
345
362
|
async function executeTerminalWait(params: ToolParams) {
|
|
346
363
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
347
364
|
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
365
|
+
// Waiting on a pane is use: keep it out of the "used this turn" cleanup nudge at settle.
|
|
366
|
+
touchTerminal(entry);
|
|
348
367
|
const raw = typeof params?.pattern === 'string' ? params.pattern : '';
|
|
349
368
|
if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
|
|
350
369
|
const useRegex = params?.regex === true;
|
package/src/plugins/todo.ts
CHANGED
|
@@ -7,16 +7,17 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import { Type } from 'typebox';
|
|
10
|
-
import
|
|
10
|
+
import { cordisGet } from '../cordis-services.ts';
|
|
11
11
|
import type { TodosService } from '../todos-service.ts';
|
|
12
|
-
import { planTodoReadHook } from '../todo-read-hook.ts';
|
|
12
|
+
import { planTodoReadHook, shouldRunTodoRead } from '../todo-read-hook.ts';
|
|
13
13
|
import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
|
|
14
14
|
import { makeProgressUpdate } from '../subagent-core.ts';
|
|
15
15
|
import { TODO_DETAILS_KEY, TODO_STATUSES, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
|
|
16
16
|
import { toolError } from '../tool-error.ts';
|
|
17
17
|
import { formatAge, isArchived } from '../stale-core.ts';
|
|
18
18
|
import { TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, validateTodos, type TodoEditOp } from '../todo-core.ts';
|
|
19
|
-
import {
|
|
19
|
+
import { formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
|
|
20
|
+
import { unifiedWidgetLines, type WorkerWidgetRow } from '../tree-view.ts';
|
|
20
21
|
import { swallow } from '../swallow.ts';
|
|
21
22
|
|
|
22
23
|
type ToolParams = Record<string, unknown> | undefined;
|
|
@@ -29,7 +30,7 @@ export interface TodoUiSlot {
|
|
|
29
30
|
cancelReminder?: () => void;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
interface TodoDeps {
|
|
33
|
+
export interface TodoDeps {
|
|
33
34
|
todos: TodosService;
|
|
34
35
|
allowParallelInProgress: boolean;
|
|
35
36
|
/** Warn above this size without rejecting otherwise valid plans. */
|
|
@@ -39,6 +40,8 @@ interface TodoDeps {
|
|
|
39
40
|
state: TodoUiSlot;
|
|
40
41
|
/** Human-gate depth (ask_user_question / herdr:blocked); >0 collapses the widget to one line. */
|
|
41
42
|
getBlockedDepth?: () => number;
|
|
43
|
+
/** D113: running subagent rows for the unified widget; master mounts only. */
|
|
44
|
+
getWorkers?: () => readonly WorkerWidgetRow[];
|
|
42
45
|
/** Master-only unfinished-todo reminder; omitted on worker panes. */
|
|
43
46
|
stopReminder?: {
|
|
44
47
|
getBlockedDepth: () => number;
|
|
@@ -58,8 +61,6 @@ const TOOL_DESCRIPTION = [
|
|
|
58
61
|
'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.',
|
|
59
62
|
].join(' ');
|
|
60
63
|
|
|
61
|
-
/** pi interactive mode renders at most ten widget lines. */
|
|
62
|
-
const WIDGET_MAX_LINES = 10;
|
|
63
64
|
|
|
64
65
|
/** `/todos <op> <query>` verbs; the value is the user-facing past-tense feedback. */
|
|
65
66
|
const OP_VERBS: Record<TodoEditOp, string> = {
|
|
@@ -69,48 +70,21 @@ const OP_VERBS: Record<TodoEditOp, string> = {
|
|
|
69
70
|
rm: 'removed',
|
|
70
71
|
};
|
|
71
72
|
|
|
72
|
-
/**
|
|
73
|
-
* Keep the active task visible within WIDGET_MAX_LINES rather than truncating to a fixed head or tail:
|
|
74
|
-
* the window anchors on the first in-progress item, then the last open item, then recent completions.
|
|
75
|
-
*/
|
|
73
|
+
/** D113 unified resident widget (see tree-view.ts for the line rules). */
|
|
76
74
|
export function widgetLines(
|
|
77
75
|
items: readonly TodoItem[],
|
|
78
|
-
opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
|
|
76
|
+
opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null; workers?: readonly WorkerWidgetRow[] },
|
|
79
77
|
): string[] {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return [`${formatTodoSummary(items)} · /todos 全量`];
|
|
85
|
-
}
|
|
86
|
-
// Collapse archived plans to two lines so stale work cannot monopolize the widget.
|
|
87
|
-
if (opts?.archivedAgeMs != null) {
|
|
88
|
-
return [
|
|
89
|
-
`${formatTodoSummary(items)} · archived ${formatAge(opts.archivedAgeMs)}`,
|
|
90
|
-
' archived — /todos 全量',
|
|
91
|
-
];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Reserve summary and overflow lines; phase headers consume the budget too, hence the shrink loop.
|
|
95
|
-
const renderBudget = WIDGET_MAX_LINES - 2;
|
|
96
|
-
const [start, end] = anchorTodoRange(
|
|
97
|
-
items,
|
|
98
|
-
(s, e) => renderTodoGroups(items.slice(s, e)).length <= renderBudget,
|
|
99
|
-
);
|
|
100
|
-
const kept = items.slice(start, end);
|
|
101
|
-
const hidden = items.filter((_, i) => i < start || i >= end);
|
|
102
|
-
const lines = [formatTodoSummary(items), ...renderTodoGroups(kept)];
|
|
103
|
-
if (hidden.length > 0) {
|
|
104
|
-
const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
|
|
105
|
-
lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
|
|
106
|
-
}
|
|
107
|
-
return lines;
|
|
78
|
+
return unifiedWidgetLines(items, opts?.workers ?? [], {
|
|
79
|
+
archivedAgeMs: opts?.archivedAgeMs,
|
|
80
|
+
blockedDepth: opts?.blockedDepth,
|
|
81
|
+
});
|
|
108
82
|
}
|
|
109
83
|
|
|
110
84
|
export default function todoPlugin(ctx: Context): void {
|
|
111
|
-
const surface = ctx
|
|
112
|
-
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth } =
|
|
113
|
-
ctx
|
|
85
|
+
const surface = cordisGet(ctx, 'pi-herdr.surface');
|
|
86
|
+
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth, getWorkers } =
|
|
87
|
+
cordisGet(ctx, 'pi-herdr.todo-deps');
|
|
114
88
|
const scoped = surface.forModule(import.meta.url);
|
|
115
89
|
|
|
116
90
|
/** Share one archive-age decision between the widget and /todos. */
|
|
@@ -129,6 +103,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
129
103
|
ui?.setWidget?.('todos', widgetLines(todos.items, {
|
|
130
104
|
archivedAgeMs: archivedAgeMs(),
|
|
131
105
|
blockedDepth: getBlockedDepth?.() ?? 0,
|
|
106
|
+
workers: getWorkers?.() ?? [],
|
|
132
107
|
}));
|
|
133
108
|
} catch {
|
|
134
109
|
/* Older pi versions may omit widget support without disabling todo tracking. */
|
|
@@ -152,6 +127,9 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
152
127
|
});
|
|
153
128
|
|
|
154
129
|
scoped.on('before_agent_start', async (eventCtx?: unknown) => {
|
|
130
|
+
// pi 1.2+ also fires this for extension-triggered runs; skip the ones that would double-inject
|
|
131
|
+
// or consume the guard cadence (stop reminder, config guide, third-party custom runs).
|
|
132
|
+
if (!shouldRunTodoRead(eventCtx)) return;
|
|
155
133
|
const plan = planTodoReadHook({
|
|
156
134
|
items: todos.items,
|
|
157
135
|
turn: todoReadTurn,
|
package/src/reducer-core.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** D102 Evidence-Preserving Reducer core: diagnostic-command recognition, secret filtering, receipt
|
|
2
2
|
* schema checking, byte-for-byte quote verification (storage I/O lives in efficiency-store.ts). */
|
|
3
|
+
import { isPlainObject } from './guards.ts';
|
|
3
4
|
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
5
6
|
|
|
@@ -104,9 +105,6 @@ function stripReceiptJsonFences(raw: string): string {
|
|
|
104
105
|
return fenced?.[1]?.trim() ?? trimmed;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
108
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
109
|
-
}
|
|
110
108
|
|
|
111
109
|
function lineNumberOf(body: string, quote: string): number | undefined {
|
|
112
110
|
const index = body.indexOf(quote);
|
|
@@ -168,7 +166,7 @@ export function validateReceipt(
|
|
|
168
166
|
return { ok: false, reason: 'invalid-json' };
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
if (!
|
|
169
|
+
if (!isPlainObject(parsed)) {
|
|
172
170
|
return { ok: false, reason: 'schema-mismatch' };
|
|
173
171
|
}
|
|
174
172
|
|
|
@@ -189,7 +187,7 @@ export function validateReceipt(
|
|
|
189
187
|
const seen = new Set<string>();
|
|
190
188
|
|
|
191
189
|
for (const item of parsed.evidence) {
|
|
192
|
-
if (!
|
|
190
|
+
if (!isPlainObject(item)) {
|
|
193
191
|
return { ok: false, reason: 'schema-mismatch' };
|
|
194
192
|
}
|
|
195
193
|
const kind = item.kind;
|
package/src/renderers.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* needed and older pi builds degrade to "no renderer".
|
|
5
5
|
*/
|
|
6
6
|
import { TODO_EDIT_CUSTOM_TYPE, type TodoEditPayload } from './todo-core.ts';
|
|
7
|
-
import { SUBS_CUSTOM_TYPE, type SubsRegistry } from './subagent-core.ts';
|
|
7
|
+
import { SUBS_CUSTOM_TYPE, PROGRESS_CUSTOM_TYPE, subRef, type SubsRegistry } from './subagent-core.ts';
|
|
8
|
+
import { DELEGATION_CUSTOM_TYPE } from './delegation-core.ts';
|
|
8
9
|
import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
|
|
9
10
|
import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
|
|
10
11
|
|
|
@@ -94,10 +95,31 @@ function subsLines(data: unknown, theme: RenderTheme, expanded: boolean): string
|
|
|
94
95
|
label: 'subagents',
|
|
95
96
|
unit: 'tracked',
|
|
96
97
|
...(running ? { badge: `${running} running` } : {}),
|
|
97
|
-
|
|
98
|
+
// Every registry row IS a delegated worker (↳ marks the delegation edge; status text carries state).
|
|
99
|
+
row: (sub) => ` ↳ ${subRef(sub)} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
|
|
98
100
|
});
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/** First-frame delegation card in the worker's own transcript (RFC rfc-delegation-identity §2.3). */
|
|
104
|
+
function delegationLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
105
|
+
const rec = (data ?? {}) as {
|
|
106
|
+
parentPaneId?: unknown;
|
|
107
|
+
parentSessionId?: unknown;
|
|
108
|
+
taskId?: unknown;
|
|
109
|
+
description?: unknown;
|
|
110
|
+
role?: unknown;
|
|
111
|
+
};
|
|
112
|
+
const parent = typeof rec.parentPaneId === 'string' && rec.parentPaneId ? rec.parentPaneId : '?';
|
|
113
|
+
const head = `${theme.fg('accent', '⟵ delegated')} ${theme.fg('dim', `by master ${parent} · ${clip(String(rec.description ?? ''), 60)}`)}`;
|
|
114
|
+
if (!expanded) return [head];
|
|
115
|
+
const session = typeof rec.parentSessionId === 'string' && rec.parentSessionId ? rec.parentSessionId : '?';
|
|
116
|
+
const role = typeof rec.role === 'string' && rec.role ? rec.role : '?';
|
|
117
|
+
return [
|
|
118
|
+
head,
|
|
119
|
+
theme.fg('dim', ` task ${typeof rec.taskId === 'string' ? rec.taskId : '?'} · role ${role} · parent session ${session}`),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
function terminalsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
102
124
|
const terminals = (data as TerminalsRegistry | undefined)?.terminals;
|
|
103
125
|
return registryLines(Array.isArray(terminals) ? terminals : undefined, theme, expanded, {
|
|
@@ -137,6 +159,17 @@ function approvalLines(data: unknown, theme: RenderTheme): string[] {
|
|
|
137
159
|
return [`${theme.fg('warning', '⚠ approval needed')} ${theme.fg('dim', `· ${tool} (${role})`)}`];
|
|
138
160
|
}
|
|
139
161
|
|
|
162
|
+
/** Phase 2: worker progress note (display-less custom message) as a one-line card — the sender
|
|
163
|
+
* pane rides `details.paneId`; keep it recognizable without flooding the transcript. */
|
|
164
|
+
function progressLines(message: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
165
|
+
const rec = (message ?? {}) as { content?: unknown; details?: { paneId?: unknown } };
|
|
166
|
+
const from = typeof rec.details?.paneId === 'string' && rec.details.paneId ? rec.details.paneId : '?';
|
|
167
|
+
const content = typeof rec.content === 'string' ? rec.content : '';
|
|
168
|
+
const head = `${theme.fg('accent', '⤴ progress')} ${theme.fg('dim', `· ${from}: ${clip(content, 100)}`)}`;
|
|
169
|
+
if (!expanded) return [head];
|
|
170
|
+
return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
|
|
171
|
+
}
|
|
172
|
+
|
|
140
173
|
/** Reminder custom messages carry the full injected text; restyle without altering it. */
|
|
141
174
|
function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
|
|
142
175
|
const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
|
|
@@ -179,8 +212,10 @@ export function installRenderers(pi: unknown): string[] {
|
|
|
179
212
|
entry(TODO_EDIT_CUSTOM_TYPE, todoEditLines);
|
|
180
213
|
entry(SUBS_CUSTOM_TYPE, subsLines);
|
|
181
214
|
entry(TERMINALS_CUSTOM_TYPE, terminalsLines);
|
|
215
|
+
entry(DELEGATION_CUSTOM_TYPE, delegationLines);
|
|
182
216
|
entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
|
|
183
217
|
entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
|
|
218
|
+
message(PROGRESS_CUSTOM_TYPE, progressLines);
|
|
184
219
|
message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
|
|
185
220
|
message(TERM_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'terminal nudge', expanded));
|
|
186
221
|
return registered;
|
package/src/role-manifest.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* top-level keys are reported (a typo like "rulez" must not silently fail) and every issue is
|
|
5
5
|
* collected in one pass so a manifest can be repaired in one go.
|
|
6
6
|
*/
|
|
7
|
+
import { isPlainObject } from './guards.ts';
|
|
8
|
+
|
|
7
9
|
export type PermissionAction = 'allow' | 'ask' | 'deny';
|
|
8
10
|
export type TodosMode = 'serial' | 'parallel';
|
|
9
11
|
/** D82: stance for unknown tools—visibility is a trust relationship (user→master via install; master→delegated via mirror). */
|
|
@@ -47,9 +49,6 @@ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
|
47
49
|
const TOOL_KEY_RE = /^[a-z0-9_*-]+$/;
|
|
48
50
|
const ACTIONS: readonly PermissionAction[] = ['allow', 'ask', 'deny'];
|
|
49
51
|
|
|
50
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
51
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
52
|
-
}
|
|
53
52
|
|
|
54
53
|
export function validateRoleManifest(input: unknown): ValidateResult {
|
|
55
54
|
const issues: string[] = [];
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "worker-default",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.4.0: 工具合并后更新 deny 列表",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.5.0: Phase 2 显式放行 report_progress(worker→master 检查点上报);v1.4.0: 工具合并后更新 deny 列表",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
7
7
|
"tools": [
|
|
8
|
-
"todo_write", "ask_user_question",
|
|
8
|
+
"todo_write", "ask_user_question", "report_progress",
|
|
9
9
|
"read", "grep", "glob", "bash", "pwsh", "edit", "write",
|
|
10
10
|
"web_search"
|
|
11
11
|
],
|