pi-pier 0.2.7 → 0.3.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.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -1,104 +0,0 @@
1
- import { execFile as nodeExecFile } from 'node:child_process'
2
- import { promisify } from 'node:util'
3
- import { runtimePolicy } from './runtime-policy.ts'
4
-
5
- const execFile = promisify(nodeExecFile)
6
-
7
- export interface GitResult {
8
- readonly stdout: string
9
- readonly stderr: string
10
- }
11
-
12
- export interface GitError extends Error {
13
- readonly code?: string | number
14
- readonly stdout?: string
15
- readonly stderr?: string
16
- }
17
-
18
- /**
19
- * Injectable execFile used by NodeGitAdapter.
20
- * Why: tests drive git without a real process.
21
- */
22
- export type GitExecFile = (
23
- file: string,
24
- args: readonly string[],
25
- options: { timeout: number; encoding: 'utf8' },
26
- ) => Promise<{ stdout: string; stderr: string }>
27
-
28
- /**
29
- * Minimal Git adapter for worktree and status operations.
30
- *
31
- * Why: Centralizes platform-specific git executable resolution, timeout policy,
32
- * and error normalization; makes tests independent of real git processes.
33
- */
34
- export interface GitAdapter {
35
- /**
36
- * List worktrees in porcelain format.
37
- * Returns stdout containing worktree entries.
38
- */
39
- listWorktrees(cwd: string): Promise<GitResult>
40
-
41
- /**
42
- * Get status of worktree in short format.
43
- * Returns stdout containing status entries.
44
- */
45
- status(cwd: string): Promise<GitResult>
46
-
47
- /**
48
- * Run an arbitrary git command in `cwd` (`git -C <cwd> ...args`).
49
- * Throws a GitError on failure/timeout — callers that want "null on error"
50
- * (isolate sweep, placement) catch at the call site.
51
- */
52
- run(cwd: string, args: readonly string[]): Promise<GitResult>
53
- }
54
- export class NodeGitAdapter implements GitAdapter {
55
- private readonly gitExecutable: string
56
- private readonly timeoutMs: number
57
- private readonly exec: GitExecFile
58
-
59
- constructor(
60
- gitExecutable: string = 'git',
61
- timeoutMs: number = runtimePolicy.gitTimeoutMs,
62
- exec: GitExecFile = execFile as unknown as GitExecFile,
63
- ) {
64
- this.gitExecutable = gitExecutable
65
- this.timeoutMs = timeoutMs
66
- this.exec = exec
67
- }
68
-
69
- listWorktrees(cwd: string): Promise<GitResult> {
70
- return this.run(cwd, ['worktree', 'list', '--porcelain'])
71
- }
72
-
73
- status(cwd: string): Promise<GitResult> {
74
- return this.run(cwd, ['status', '--short'])
75
- }
76
-
77
- async run(cwd: string, args: readonly string[]): Promise<GitResult> {
78
- try {
79
- const { stdout, stderr } = await this.exec(
80
- this.gitExecutable,
81
- ['-C', cwd, ...args],
82
- { timeout: this.timeoutMs, encoding: 'utf8' },
83
- )
84
- return { stdout, stderr }
85
- } catch (err) {
86
- throw this.normalizeError(err, args[0] ?? 'command')
87
- }
88
- }
89
-
90
- private normalizeError(err: unknown, operation: string): GitError {
91
- if (err instanceof Error) {
92
- const gitError = err as GitError
93
- gitError.message = `Git ${operation} failed: ${gitError.message}`
94
- return gitError
95
- }
96
- return new Error(`Git ${operation} failed: ${String(err)}`) as GitError
97
- }
98
- }
99
-
100
- /**
101
- * Default production git adapter.
102
- * Tests should inject fake implementations or a fake exec.
103
- */
104
- export const defaultGitAdapter = new NodeGitAdapter()
@@ -1,23 +0,0 @@
1
- /**
2
- * D92 settlement-notice collapser (pure half of the followup-flood fix).
3
- *
4
- * Why: `sendUserMessage(followUp)` only delivers when the agent has no more
5
- * tool calls, so a long master run queues every settlement and floods at the
6
- * end. Buffer while busy; collapse at turn_end. The other half is the index
7
- * notice buffer.
8
- */
9
- export const NOTICE_MAX_SHOWN = 3;
10
-
11
- /**
12
- * Collapse a batch into one message body.
13
- * - empty → null (do not inject)
14
- * - ≤max → originals joined with `\n\n` (single-item byte-identical to the old format)
15
- * - >max → first max originals + a tail pointing at history / subagent list
16
- */
17
- export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
18
- if (contents.length === 0) return null;
19
- if (contents.length <= max) return contents.join('\n\n');
20
- const hidden = contents.length - max;
21
- const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 subagent resume 工具描述);在跑代理用 subagent list 查看。`;
22
- return [...contents.slice(0, max), tail].join('\n\n');
23
- }
@@ -1,80 +0,0 @@
1
- /**
2
- * Git/worktree I/O used by spawn, isolate, GC, and settlement stat lines.
3
- *
4
- * Why: core/subagent mixed adapter calls with tool registration. Centralizing
5
- * git behind one cache/timeout policy keeps placement and isolate GC consistent.
6
- */
7
- import { defaultGitAdapter, type GitAdapter } from './git-adapter.ts';
8
- import { formatWorktreeStat, type SubEntry } from './subagent-core.ts';
9
-
10
- const WORKTREES_CACHE_MS = 5000;
11
- export interface GitIo {
12
- listWorktrees(cwd: string): Promise<string[]>;
13
- runGit(cwd: string, args: string[]): Promise<string | null>;
14
- worktreeStatLine(entry: SubEntry): Promise<string | null>;
15
- invalidateWorktreesCache(): void;
16
- }
17
-
18
- export function createGitIo(git: GitAdapter = defaultGitAdapter): GitIo {
19
- let worktreesCache: { at: number; list: string[] } | null = null;
20
- async function listWorktrees(cwd: string): Promise<string[]> {
21
- if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
22
- let list: string[] = [];
23
- try {
24
- const { stdout } = await git.listWorktrees(cwd);
25
- for (const line of String(stdout).split('\n')) {
26
- const m = /^worktree (.+)$/.exec(line.trim());
27
- if (m) list.push(m[1]);
28
- }
29
- } catch {
30
- list = [];
31
- }
32
- worktreesCache = { at: Date.now(), list };
33
- return list;
34
- }
35
-
36
- async function runGit(cwd: string, args: string[]): Promise<string | null> {
37
- try {
38
- const { stdout } = await git.run(cwd, args);
39
- return stdout == null ? null : String(stdout);
40
- } catch {
41
- return null;
42
- }
43
- }
44
-
45
- function lastStatLine(out: string | null): string | null {
46
- if (!out) return null;
47
- const lines = out.replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
48
- return lines.length ? lines[lines.length - 1]! : null;
49
- }
50
-
51
- async function worktreeStatLine(entry: SubEntry): Promise<string | null> {
52
- const porcelain = await runGit(entry.cwd, ['status', '--porcelain']);
53
- if (porcelain === null) return null;
54
- const dirtyCount = porcelain.split('\n').filter((l) => l.trim() !== '').length;
55
- if (entry.isolate) {
56
- const statOut = await runGit(entry.cwd, ['diff', '--stat', `${entry.isolate.baseSha}...HEAD`]);
57
- const commitsOut = await runGit(entry.cwd, ['rev-list', '--count', `${entry.isolate.baseSha}..HEAD`]);
58
- const commits = commitsOut != null && /^\d+$/.test(commitsOut.trim()) ? Number(commitsOut.trim()) : null;
59
- return formatWorktreeStat({ branch: entry.isolate.branch, commits, statLine: lastStatLine(statOut), dirtyCount });
60
- }
61
- return formatWorktreeStat({
62
- branch: null,
63
- commits: null,
64
- statLine: lastStatLine(await runGit(entry.cwd, ['diff', '--stat', 'HEAD'])),
65
- dirtyCount,
66
- });
67
- }
68
-
69
- function invalidateWorktreesCache(): void {
70
- worktreesCache = null;
71
- }
72
-
73
- return {
74
- listWorktrees,
75
- runGit,
76
- worktreeStatLine,
77
- invalidateWorktreesCache,
78
- };
79
- }
80
-
@@ -1,110 +0,0 @@
1
- /**
2
- * Subagent `execute` planners (validation + foreground wait).
3
- *
4
- * Why: execute mixed param checks, isolate git I/O, spawn, and wait-loop
5
- * decisions. The decisions are pure and must stay testable without a pane.
6
- */
7
- import { normalizeEntryKind } from './history-store.ts';
8
-
9
- export const FOREGROUND_POLL_MS = 2_000;
10
-
11
- export type LaunchParams = {
12
- description?: unknown;
13
- prompt?: unknown;
14
- run_in_background?: unknown;
15
- cwd?: unknown;
16
- isolate?: unknown;
17
- role?: unknown;
18
- allowed_tools?: unknown;
19
- tab?: unknown;
20
- };
21
-
22
- export type LaunchValidation =
23
- | { kind: 'error'; text: string }
24
- | {
25
- kind: 'ok';
26
- spec: SubagentSpec;
27
- background: boolean;
28
- isolate: boolean;
29
- cwdParam: string | null;
30
- roleKind: string;
31
- suggested: string[];
32
- tab: string | null;
33
- };
34
-
35
- export function planLaunchValidation(
36
- params: LaunchParams | null | undefined,
37
- herdrAvailable: boolean,
38
- ): LaunchValidation {
39
- if (!herdrAvailable) {
40
- return {
41
- kind: 'error',
42
- text: 'Error: subagent requires pi to run inside a herdr-managed pane (HERDR_ENV not set).',
43
- };
44
- }
45
- const spec: SubagentSpec = {
46
- description: String(params?.description ?? 'subagent'),
47
- prompt: String(params?.prompt ?? ''),
48
- };
49
- if (!spec.prompt.trim()) {
50
- return { kind: 'error', text: 'Error: `prompt` must be a non-empty string' };
51
- }
52
- const cwdParam = typeof params?.cwd === 'string' && params.cwd.trim() ? params.cwd.trim() : null;
53
- const isolate = params?.isolate === true;
54
- if (isolate && cwdParam) {
55
- return {
56
- kind: 'error',
57
- text: 'Error: `isolate` and `cwd` are mutually exclusive — isolate creates a new worktree, cwd delegates into an existing one',
58
- };
59
- }
60
- const suggested = Array.isArray(params?.allowed_tools)
61
- ? params.allowed_tools.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
62
- : [];
63
- const manifestRole =
64
- typeof params?.role === 'string' && params.role.trim() ? params.role.trim() : 'worker-default';
65
- const tab = typeof params?.tab === 'string' ? params.tab : null;
66
- return {
67
- kind: 'ok',
68
- spec,
69
- background: params?.run_in_background === true,
70
- isolate,
71
- cwdParam,
72
- roleKind: normalizeEntryKind(typeof params?.role === 'string' ? params.role.trim() : undefined),
73
- suggested,
74
- manifestRole,
75
- tab,
76
- };
77
- }
78
-
79
- export function planIsolateRepoGuard(baseSha: string | null): { kind: 'error'; text: string } | { kind: 'ok'; sha: string } {
80
- if (!baseSha) {
81
- return { kind: 'error', text: 'Error: isolate requires a git repository with at least one commit' };
82
- }
83
- return { kind: 'ok', sha: baseSha };
84
- }
85
-
86
- export type ForegroundTickPlan =
87
- | { kind: 'blocked' }
88
- | { kind: 'settled'; text: string }
89
- | { kind: 'wait'; delayMs: number }
90
- | { kind: 'collect-final' }
91
- | { kind: 'continue' };
92
-
93
- export function planForegroundTick(input: {
94
- state: string | null;
95
- session: { text: string | null; pendingTool: boolean; activity: boolean };
96
- }): ForegroundTickPlan {
97
- if (input.state === 'blocked') return { kind: 'blocked' };
98
- if (input.state === 'idle' || input.state === 'done') {
99
- if (input.session.text) return { kind: 'settled', text: input.session.text };
100
- if (input.session.pendingTool || !input.session.activity) {
101
- return { kind: 'wait', delayMs: FOREGROUND_POLL_MS };
102
- }
103
- return { kind: 'collect-final' };
104
- }
105
- return { kind: 'continue' };
106
- }
107
-
108
- export function planPatienceExpiry(alive: boolean): 'move-to-background' | 'timeout' {
109
- return alive ? 'move-to-background' : 'timeout';
110
- }
@@ -1,285 +0,0 @@
1
- /**
2
- * Background settlement poller (takeover, blocked gate, observation, vacuum).
3
- *
4
- * Why: planners already live in subagent-poller.ts; this adapter owns the
5
- * herdr/session I/O loop and pane-scope lifecycle so the plugin only starts it.
6
- */
7
- import { appendFileSync } from 'node:fs';
8
- import type { Context } from '@deepseek-ai/cordis';
9
- import type { HerdrClientLike, HerdrAgentState } from './herdr-client.ts';
10
- import { applyReportedSessionFile } from './history-store.ts';
11
- import { runtimePolicy } from './runtime-policy.ts';
12
- import { mountSubagentScope } from './subagent-scope.ts';
13
- import {
14
- OBSERVATION_TICK_MS,
15
- TAKEOVER_RECHECK_MS,
16
- planBlockedGate,
17
- planObservationTick,
18
- planTakeoverTick,
19
- planVacuumTick,
20
- } from './subagent-poller.ts';
21
- import { buildBlockedGateNotice, type SubEntry } from './subagent-core.ts';
22
- import { formatSettlementNotice } from './vocab.ts';
23
- import type { SessionIo } from './subagent-session-io.ts';
24
- import type { GitIo } from './subagent-git-io.ts';
25
-
26
- function sleep(ms: number): Promise<void> {
27
- const wait = Promise.withResolvers<void>();
28
- setTimeout(wait.resolve, ms);
29
- return wait.promise;
30
- }
31
-
32
- export interface PollerHost {
33
- client: HerdrClientLike;
34
- sessionRoot: Context;
35
- subs: Map<string, SubEntry>;
36
- persistSubs(): void;
37
- writeHistory(e: SubEntry, patch?: { outcome?: string | null; status?: SubEntry['status']; closedAt?: number }, via?: string): void;
38
- blockedGateNotified: Set<string>;
39
- lastMachineInjectAt: Map<string, number>;
40
- session: SessionIo;
41
- git: GitIo;
42
- injectNotice(content: string): Promise<void>;
43
- reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
44
- withReconcileNotes(base: string, notes: readonly string[]): string;
45
- claimSettleNotice(key: string): boolean;
46
- }
47
-
48
- export interface Poller {
49
- startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void;
50
- readonly pollers: Set<string>;
51
- }
52
-
53
- export function createPoller(h: PollerHost): Poller {
54
- const pollers = new Set<string>();
55
- const subScopes = new Map<string, { dispose: () => Promise<void> }>();
56
- const observeWindowMs = runtimePolicy.observationWindowMs;
57
- const machineInjectGraceMs = runtimePolicy.settlementWindowMs;
58
- const takeoverIdleMs = runtimePolicy.settlementWindowMs;
59
- const timeoutMs = runtimePolicy.subagentTimeoutMs;
60
-
61
- async function pollLoop(
62
- paneId: string,
63
- cwd: string,
64
- spawnedAt: number,
65
- injectTs: number,
66
- description: string,
67
- requestId: string,
68
- ): Promise<void> {
69
- void spawnedAt;
70
- const startedAt = Date.now();
71
- let lastActivityAt = Date.now();
72
- const pollTrace = process.env.PI_HERDR_TRACE
73
- ? (msg: string) => { try { appendFileSync(process.env.PI_HERDR_TRACE!, `d98poll ${Date.now()} ${paneId} ${msg}\n`); } catch { /* best-effort */ } }
74
- : null;
75
- try {
76
- while (true) {
77
- const entry = h.subs.get(paneId);
78
- if (!entry || entry.status === 'settled') return;
79
-
80
- if (entry.userTakeover) {
81
- try {
82
- const agents = await h.client.listAgents();
83
- const agent = agents.find((a) => a.paneId === paneId);
84
- const tick = planTakeoverTick({
85
- currentStatus: agent?.status ?? null,
86
- previousStatus: entry.lastAgentStatus,
87
- idleStartedAt: entry.observationStartedAt,
88
- now: Date.now(),
89
- idleMs: takeoverIdleMs,
90
- });
91
- if (tick.kind === 'start-idle') {
92
- entry.observationStartedAt = Date.now();
93
- entry.lastAgentStatus = 'idle';
94
- h.persistSubs();
95
- } else if (tick.kind === 'return-control') {
96
- entry.userTakeover = false;
97
- entry.observationStartedAt = null;
98
- entry.lastAgentStatus = null;
99
- h.persistSubs();
100
- } else if (tick.kind === 'hold' && tick.clearIdleTimer) {
101
- entry.lastAgentStatus = tick.lastAgentStatus;
102
- entry.observationStartedAt = null;
103
- h.persistSubs();
104
- }
105
- } catch {
106
- /* status probe failure must not stop the poller */
107
- }
108
- if (entry.userTakeover) {
109
- await sleep(TAKEOVER_RECHECK_MS);
110
- continue;
111
- }
112
- }
113
-
114
- let state: HerdrAgentState | null;
115
- try {
116
- state = await h.client.waitAgent(paneId, ['idle', 'done', 'blocked'], runtimePolicy.pollIntervalMs);
117
- } catch {
118
- state = null;
119
- }
120
- const gate = planBlockedGate(state, h.blockedGateNotified.has(paneId));
121
- if (gate.kind === 'stay-blocked') {
122
- if (gate.notify) {
123
- h.blockedGateNotified.add(paneId);
124
- const question = await h.session.readAskFlag(paneId);
125
- try {
126
- await h.injectNotice(buildBlockedGateNotice({ paneId, description, question }));
127
- } catch {
128
- /* list_agents can recover a missed notice */
129
- }
130
- }
131
- continue;
132
- }
133
- if (gate.kind === 'clear-gate') h.blockedGateNotified.delete(paneId);
134
- if (state === 'idle' || state === 'done') {
135
- const s = await h.session.subSessionState(paneId, cwd, injectTs);
136
- pollTrace?.(`state=${state} text=${s.text ? s.text.length : 'null'} pend=${s.pendingTool} act=${s.activity} obs=${String(entry.observationStartedAt ?? null)} takeover=${String(entry.userTakeover === true)}`);
137
- if (s.text || (!s.pendingTool && s.activity)) {
138
- const closing = s.text;
139
- const obs = planObservationTick({
140
- observationStartedAt: entry.observationStartedAt,
141
- now: Date.now(),
142
- windowMs: observeWindowMs,
143
- agentStatus: null,
144
- machineInjectAgoMs: Date.now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
145
- machineInjectGraceMs,
146
- });
147
- if (obs.kind === 'start-observation') {
148
- entry.observationStartedAt = Date.now();
149
- entry.lastAgentStatus = 'idle';
150
- h.persistSubs();
151
- await sleep(OBSERVATION_TICK_MS);
152
- continue;
153
- }
154
- let agentStatus: string | null = null;
155
- try {
156
- const agents = await h.client.listAgents();
157
- agentStatus = agents.find((a) => a.paneId === paneId)?.status ?? null;
158
- } catch {
159
- /* observation continues */
160
- }
161
- const obs2 = planObservationTick({
162
- observationStartedAt: entry.observationStartedAt,
163
- now: Date.now(),
164
- windowMs: observeWindowMs,
165
- agentStatus,
166
- machineInjectAgoMs: Date.now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
167
- machineInjectGraceMs,
168
- });
169
- if (obs2.kind === 'user-takeover') {
170
- entry.userTakeover = true;
171
- entry.observationStartedAt = Date.now();
172
- entry.lastAgentStatus = 'working';
173
- h.persistSubs();
174
- continue;
175
- }
176
- if (obs2.kind === 'machine-inject-reset') {
177
- entry.observationStartedAt = Date.now();
178
- h.persistSubs();
179
- await sleep(OBSERVATION_TICK_MS);
180
- continue;
181
- }
182
- if (obs2.kind === 'wait') {
183
- await sleep(OBSERVATION_TICK_MS);
184
- continue;
185
- }
186
- entry.observationStartedAt = null;
187
- if (!entry.sessionFile) {
188
- entry.sessionFile = applyReportedSessionFile(
189
- entry.sessionFile,
190
- await h.session.resolveSessionFile(paneId, cwd),
191
- );
192
- }
193
- entry.status = 'consumed';
194
- entry.consumedAt = Date.now();
195
- h.writeHistory(entry, { outcome: closing }, 'poll-settle');
196
- const notes = h.reconcileOnSettlement(description, 'settled');
197
- const statLine = await h.git.worktreeStatLine(entry);
198
- const notice = h.withReconcileNotes(
199
- formatSettlementNotice(`${paneId} (${description})`, closing) + (statLine ? `\n${statLine}` : ''),
200
- notes,
201
- );
202
- if (h.claimSettleNotice(`${paneId}:${requestId}`)) {
203
- try {
204
- await h.injectNotice(notice);
205
- } catch {
206
- /* list_agents can recover */
207
- }
208
- }
209
- return;
210
- }
211
- }
212
- let alive = false;
213
- try {
214
- alive = (await h.client.listAgents()).some((a) => a.paneId === paneId);
215
- } catch {
216
- alive = true;
217
- }
218
- const vacuum = planVacuumTick({
219
- waitState: state,
220
- paneAlive: alive,
221
- now: Date.now(),
222
- lastActivityAt,
223
- timeoutMs,
224
- });
225
- if (vacuum.refreshActivity) lastActivityAt = Date.now();
226
- if (vacuum.action === 'pane-closed') {
227
- entry.status = 'consumed';
228
- entry.consumedAt = Date.now();
229
- h.persistSubs();
230
- h.writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
231
- const notes = h.reconcileOnSettlement(description, 'failed');
232
- const notice = h.withReconcileNotes(
233
- `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
234
- notes,
235
- );
236
- try {
237
- await h.injectNotice(notice);
238
- } catch { /* non-fatal */ }
239
- return;
240
- }
241
- if (vacuum.action === 'timeout') {
242
- entry.status = 'consumed';
243
- entry.consumedAt = Date.now();
244
- h.persistSubs();
245
- h.writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
246
- const notes = h.reconcileOnSettlement(description, 'failed');
247
- const notice = h.withReconcileNotes(
248
- `Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run subagent(action: "list") to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
249
- notes,
250
- );
251
- try {
252
- await h.injectNotice(notice);
253
- } catch { /* non-fatal */ }
254
- return;
255
- }
256
- }
257
- } finally {
258
- pollers.delete(paneId);
259
- }
260
- }
261
-
262
- function startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void {
263
- if (pollers.has(paneId)) return;
264
- pollers.add(paneId);
265
- void (async () => {
266
- try {
267
- if (!subScopes.has(paneId)) {
268
- const fiber = await mountSubagentScope(h.sessionRoot, paneId, {
269
- onDispose: () => { pollers.delete(paneId); },
270
- });
271
- subScopes.set(paneId, fiber);
272
- }
273
- await pollLoop(paneId, cwd, spawnedAt, injectTs, description, requestId);
274
- const fiber = subScopes.get(paneId);
275
- subScopes.delete(paneId);
276
- try { await fiber?.dispose(); } catch { /* already gone */ }
277
- } catch (err) {
278
- pollers.delete(paneId);
279
- console.error(`pier: subagent poller ${paneId} crashed: ${(err as Error)?.message ?? err}`);
280
- }
281
- })();
282
- }
283
-
284
- return { startPoller, pollers };
285
- }
@@ -1,27 +0,0 @@
1
- /**
2
- * Subagent outbound port (composition root → plugin).
3
- *
4
- * Why: index used to pass a bag of independently-nullable callbacks that the
5
- * plugin mutated field-by-field. A missing bag crashed at mount
6
- * (`Cannot set properties of undefined (setting 'applyReplySession')`).
7
- * Binding one `current` object is atomic: all methods or none.
8
- */
9
- export interface RunningSub {
10
- paneId: string;
11
- description: string;
12
- }
13
-
14
- export interface SubagentPort {
15
- applyReplySession(paneId: string, sessionFile: string | null): void;
16
- reconcileOnReply(paneId: string): string[];
17
- listRunningSubs(): RunningSub[];
18
- settleStatLine(paneId: string): Promise<string | null>;
19
- }
20
-
21
- export interface SubagentPortBox {
22
- current: SubagentPort | null;
23
- }
24
-
25
- export function emptySubagentPortBox(): SubagentPortBox {
26
- return { current: null };
27
- }
@@ -1,39 +0,0 @@
1
- /**
2
- * One Cordis fiber per subagent in the master process.
3
- * pipe / poller / history cleanup uses ctx.effect (LIFO dispose).
4
- * C3: workers must not import this module.
5
- */
6
- import { Context } from '@deepseek-ai/cordis';
7
-
8
- export interface ScopeHooks {
9
- onDispose?: () => void;
10
- }
11
-
12
- export function createSessionRoot(hooks: ScopeHooks = {}): Context {
13
- const root = new Context();
14
- if (hooks.onDispose) {
15
- root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
16
- }
17
- return root;
18
- }
19
-
20
- export async function mountSubagentScope(
21
- root: Context,
22
- paneId: string,
23
- hooks: ScopeHooks = {},
24
- ) {
25
- return root.plugin({
26
- name: `subagent:${paneId}`,
27
- apply(ctx: Context) {
28
- ctx.effect(() => () => { hooks.onDispose?.(); }, `subagent:${paneId}`);
29
- },
30
- }, { paneId });
31
- }
32
-
33
- export async function disposeSessionRoot(root: Context): Promise<void> {
34
- try {
35
- await root.fiber.dispose();
36
- } catch {
37
- /* second dispose is a no-op */
38
- }
39
- }