pi-pier 0.3.0 → 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 +16 -12
- package/package.json +3 -3
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +10 -13
- package/src/compact-coordinator.ts +279 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +61 -22
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/focus-poller.ts +0 -244
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/subagent-port.ts
DELETED
|
@@ -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
|
-
}
|
package/src/subagent-ready.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A14: spawn readiness had a single 30 s ping window and reported failures as one bare line
|
|
3
|
-
* ("subagent pane wD:pJ pipe not ready within 30000ms"), even when the child process had already
|
|
4
|
-
* died with a stack trace on its screen. With 3–4 concurrent isolated workers (each running heavy
|
|
5
|
-
* commands) that window was hit repeatedly, and the diagnosis cost far more than the retry would.
|
|
6
|
-
*
|
|
7
|
-
* This module is the pure half: how long to wait, how to back off, when to give up early
|
|
8
|
-
* (the pane is gone — no point in waiting for a pipe that cannot appear) and how to word the
|
|
9
|
-
* failure so the model and the human can act on it.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** Ping cadence: quick first probes, then exponential backoff capped at `READY_MAX_INTERVAL_MS`. */
|
|
13
|
-
export const READY_BASE_INTERVAL_MS = 500;
|
|
14
|
-
export const READY_MAX_INTERVAL_MS = 4000;
|
|
15
|
-
|
|
16
|
-
/** Wait this long between two samples of the child pane's liveness (pane.list + pane read). */
|
|
17
|
-
export const READY_LIVENESS_SAMPLE_MS = 2500;
|
|
18
|
-
|
|
19
|
-
export interface ReadyProbe {
|
|
20
|
-
/** The pane still exists in pane.list (unknown shells count as alive). */
|
|
21
|
-
readonly paneAlive: boolean;
|
|
22
|
-
/** Tail of the pane's screen, used only to explain a failure. */
|
|
23
|
-
readonly tail?: string | null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export type ReadyAttemptPlan =
|
|
27
|
-
| { readonly kind: 'ready' }
|
|
28
|
-
| { readonly kind: 'retry'; readonly delayMs: number }
|
|
29
|
-
| { readonly kind: 'give-up'; readonly reason: 'pane-gone' | 'timeout' };
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Decide one readiness attempt.
|
|
33
|
-
* `alive` is tri-state on purpose: an unavailable liveness probe (`null`) must not be read as death,
|
|
34
|
-
* otherwise a slow herdr socket would fail spawns that are merely slow to boot.
|
|
35
|
-
*/
|
|
36
|
-
export function planReadyAttempt(opts: {
|
|
37
|
-
elapsedMs: number;
|
|
38
|
-
attempt: number;
|
|
39
|
-
timeoutMs: number;
|
|
40
|
-
alive: boolean | null;
|
|
41
|
-
ready: boolean;
|
|
42
|
-
}): ReadyAttemptPlan {
|
|
43
|
-
if (opts.ready) return { kind: 'ready' };
|
|
44
|
-
if (opts.alive === false) return { kind: 'give-up', reason: 'pane-gone' };
|
|
45
|
-
if (opts.elapsedMs >= opts.timeoutMs) return { kind: 'give-up', reason: 'timeout' };
|
|
46
|
-
return { kind: 'retry', delayMs: readyBackoffMs(opts.attempt) };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Exponential backoff with a cap: 500ms, 1s, 2s, 4s, 4s, … */
|
|
50
|
-
export function readyBackoffMs(attempt: number, baseMs = READY_BASE_INTERVAL_MS, capMs = READY_MAX_INTERVAL_MS): number {
|
|
51
|
-
const n = Math.max(0, Math.floor(attempt));
|
|
52
|
-
return Math.min(capMs, baseMs * 2 ** n);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** How many characters of pane tail to attach to a failure (enough for a stack trace head). */
|
|
56
|
-
export const READY_TAIL_CHARS = 1200;
|
|
57
|
-
|
|
58
|
-
export interface ReadyFailure {
|
|
59
|
-
readonly paneId: string;
|
|
60
|
-
readonly reason: 'pane-gone' | 'timeout';
|
|
61
|
-
readonly elapsedMs: number;
|
|
62
|
-
readonly timeoutMs: number;
|
|
63
|
-
/** Some agent states the wait observed (working/idle/…), for the "agent never reported" case. */
|
|
64
|
-
readonly lastStatus?: string | null;
|
|
65
|
-
readonly tail?: string | null;
|
|
66
|
-
/** True when the child process exited (pane gone) rather than never starting to report. */
|
|
67
|
-
readonly hint?: string | null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Failure text for the model. The three cases it must distinguish:
|
|
72
|
-
* 1. the pane died (usually the child crashed — the tail carries the reason, cf. A13's stack trace);
|
|
73
|
-
* 2. the pane is alive but never opened its pipe (extension failed to load, wrong pane env);
|
|
74
|
-
* 3. the pane is alive and its agent is working, just slower than the timeout (retry is safe).
|
|
75
|
-
*/
|
|
76
|
-
export function readyFailureText(failure: ReadyFailure): string {
|
|
77
|
-
const seconds = Math.round(failure.elapsedMs / 1000);
|
|
78
|
-
const head = failure.reason === 'pane-gone'
|
|
79
|
-
? `subagent pane ${failure.paneId} exited before its pipe became ready (waited ${seconds}s)`
|
|
80
|
-
: `subagent pane ${failure.paneId} pipe not ready within ${seconds}s (limit ${Math.round(failure.timeoutMs / 1000)}s)`;
|
|
81
|
-
const why = failure.reason === 'pane-gone'
|
|
82
|
-
? 'The child process is gone — its last output below usually carries the reason.'
|
|
83
|
-
: failure.lastStatus === 'working'
|
|
84
|
-
? 'The pane is alive and working, so the prompt may simply be slow to boot; retrying the same call is safe.'
|
|
85
|
-
: 'The pane is alive but never registered its pipe — the child pi process may still be starting, or it failed to load the pier extension.';
|
|
86
|
-
const tail = failure.tail ? `\nlast output of ${failure.paneId}:\n${failure.tail}` : '';
|
|
87
|
-
const hint = failure.hint ? `\n${failure.hint}` : '';
|
|
88
|
-
return `${head}\n${why}${hint}${tail}`;
|
|
89
|
-
}
|
package/src/subagent-registry.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { HerdrClientLike } from './herdr-client.ts';
|
|
2
|
-
import { foldSubsRegistry, makeRegistry, SUBS_CUSTOM_TYPE, type SubEntry } from './subagent-core.ts';
|
|
3
|
-
import { swallow } from './swallow.ts';
|
|
4
|
-
|
|
5
|
-
export interface SubagentRegistryHost {
|
|
6
|
-
pi: { appendEntry?: (customType: string, data: unknown) => void };
|
|
7
|
-
client: HerdrClientLike;
|
|
8
|
-
subs: Map<string, SubEntry>;
|
|
9
|
-
writeHistory: (entry: SubEntry, patch?: { status?: SubEntry['status']; closedAt?: number }, via?: string) => void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface SubagentRegistry {
|
|
13
|
-
/** Append the registry snapshot to the session branch; duplicate snapshots are skipped (O1). */
|
|
14
|
-
persist(): void;
|
|
15
|
-
/** Replay `pi-herdr.subs` entries from the session branch into the live map. */
|
|
16
|
-
rebuild(eventCtx: unknown): void;
|
|
17
|
-
/** B6: close running rows whose pane herdr no longer lists. */
|
|
18
|
-
sweepZombieRunning(): Promise<void>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Owns durable registry projection and startup recovery, separate from tool actions. */
|
|
22
|
-
export function createSubagentRegistry(host: SubagentRegistryHost): SubagentRegistry {
|
|
23
|
-
let lastSnapshot = '';
|
|
24
|
-
|
|
25
|
-
function persist(): void {
|
|
26
|
-
try {
|
|
27
|
-
const registry = makeRegistry([...host.subs.values()]);
|
|
28
|
-
const snapshot = JSON.stringify(registry);
|
|
29
|
-
if (snapshot === lastSnapshot) return;
|
|
30
|
-
lastSnapshot = snapshot;
|
|
31
|
-
host.pi.appendEntry?.(SUBS_CUSTOM_TYPE, registry);
|
|
32
|
-
} catch (err) {
|
|
33
|
-
// Best-effort: session logging must not break delegation, but a ghost running subagent is the
|
|
34
|
-
// symptom when this fails silently, so keep the reason queryable (B9).
|
|
35
|
-
swallow('subagent.persist-subs', err);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function rebuild(eventCtx: unknown): void {
|
|
40
|
-
try {
|
|
41
|
-
const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
42
|
-
?.sessionManager?.getBranch?.() ?? [];
|
|
43
|
-
const registry = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
44
|
-
for (const entry of registry.subs) host.subs.set(entry.paneId, entry);
|
|
45
|
-
lastSnapshot = ''; // Branch replay replaced live state; force the next persist.
|
|
46
|
-
} catch {
|
|
47
|
-
/* Registry recovery failure must not block the live session. */
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function sweepZombieRunning(): Promise<void> {
|
|
52
|
-
if (!host.client.available || host.subs.size === 0) return;
|
|
53
|
-
let livePaneIds: ReadonlySet<string>;
|
|
54
|
-
try {
|
|
55
|
-
livePaneIds = new Set((await host.client.listPanes()).map((pane) => pane.paneId));
|
|
56
|
-
} catch {
|
|
57
|
-
return; // Do not close agents when the liveness lookup itself failed.
|
|
58
|
-
}
|
|
59
|
-
let changed = false;
|
|
60
|
-
for (const [paneId, entry] of host.subs) {
|
|
61
|
-
if (entry.status !== 'running' || livePaneIds.has(paneId)) continue;
|
|
62
|
-
entry.status = 'closed';
|
|
63
|
-
host.writeHistory(entry, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
|
|
64
|
-
changed = true;
|
|
65
|
-
}
|
|
66
|
-
if (changed) persist();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { persist, rebuild, sweepZombieRunning };
|
|
70
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subagent task-id lookup rules.
|
|
3
|
-
*
|
|
4
|
-
* Kept separate from the Cordis plugin so prefix resolution remains usable without mounting the
|
|
5
|
-
* master tree and can be verified independently from pane lifecycle code.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export type TaskIdResolutionResult =
|
|
9
|
-
| { kind: 'resolved'; taskId: string }
|
|
10
|
-
| { kind: 'ambiguous'; query: string; candidates: string[] }
|
|
11
|
-
| { kind: 'too_short'; query: string }
|
|
12
|
-
| { kind: 'not_found'; query: string };
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Resolve a full or short task ID against known candidates.
|
|
16
|
-
*
|
|
17
|
-
* Exact matches are accepted regardless of length. Prefix matching requires four characters and
|
|
18
|
-
* rejects ambiguity with a sorted candidate list.
|
|
19
|
-
*/
|
|
20
|
-
export function resolveTaskIdPrefix(
|
|
21
|
-
query: string,
|
|
22
|
-
candidates: Iterable<string>,
|
|
23
|
-
): TaskIdResolutionResult {
|
|
24
|
-
const trimmed = query.trim();
|
|
25
|
-
if (!trimmed) return { kind: 'not_found', query: trimmed };
|
|
26
|
-
|
|
27
|
-
const unique = Array.from(new Set(candidates));
|
|
28
|
-
const exact = unique.find((candidate) => candidate === trimmed);
|
|
29
|
-
if (exact) return { kind: 'resolved', taskId: exact };
|
|
30
|
-
|
|
31
|
-
if (trimmed.length < 4) return { kind: 'too_short', query: trimmed };
|
|
32
|
-
|
|
33
|
-
const lower = trimmed.toLowerCase();
|
|
34
|
-
const matches = unique.filter((candidate) => candidate.toLowerCase().startsWith(lower));
|
|
35
|
-
if (matches.length === 1) return { kind: 'resolved', taskId: matches[0]! };
|
|
36
|
-
if (matches.length > 1) {
|
|
37
|
-
matches.sort();
|
|
38
|
-
return { kind: 'ambiguous', query: trimmed, candidates: matches };
|
|
39
|
-
}
|
|
40
|
-
return { kind: 'not_found', query: trimmed };
|
|
41
|
-
}
|
package/src/subagent-scope.ts
DELETED
|
@@ -1,41 +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
|
-
// Why no second argument: the pinned cordis version takes only the plugin object here; the pane id
|
|
26
|
-
// already identifies the scope through the plugin name and the effect label.
|
|
27
|
-
return root.plugin({
|
|
28
|
-
name: `subagent:${paneId}`,
|
|
29
|
-
apply(ctx: Context) {
|
|
30
|
-
ctx.effect(() => () => { hooks.onDispose?.(); }, `subagent:${paneId}`);
|
|
31
|
-
},
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function disposeSessionRoot(root: Context): Promise<void> {
|
|
36
|
-
try {
|
|
37
|
-
await root.fiber.dispose();
|
|
38
|
-
} catch {
|
|
39
|
-
/* second dispose is a no-op */
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Child-session JSONL I/O: path resolution, settlement text, liveness probes.
|
|
3
|
-
*
|
|
4
|
-
* Why: pollLoop, foreground wait, and revive all shared the same candidate
|
|
5
|
-
* order (reported path/id before recent-file fallback, excluding the parent
|
|
6
|
-
* session). Keeping it in one adapter prevents settlement text from crossing sessions.
|
|
7
|
-
*/
|
|
8
|
-
import { accessSync, constants, statSync } from 'node:fs';
|
|
9
|
-
import type { HerdrClientLike } from './herdr-client.ts';
|
|
10
|
-
import {
|
|
11
|
-
deriveSubSessionState,
|
|
12
|
-
lastAssistantText,
|
|
13
|
-
listSessionFiles,
|
|
14
|
-
readSessionFile,
|
|
15
|
-
sessionFileById,
|
|
16
|
-
type SessionEntryLike,
|
|
17
|
-
type SubSessionState,
|
|
18
|
-
} from './session-tail.ts';
|
|
19
|
-
import type { AliveProbe } from './subagent-core.ts';
|
|
20
|
-
|
|
21
|
-
export interface SessionIoHost {
|
|
22
|
-
client: HerdrClientLike;
|
|
23
|
-
getSessionId: () => string;
|
|
24
|
-
sessionsDir: () => string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface SessionIo {
|
|
28
|
-
resolveSessionFileCandidates(paneId: string, cwd: string): Promise<string[]>;
|
|
29
|
-
resolveSessionFile(paneId: string, cwd: string): Promise<string | null>;
|
|
30
|
-
collectFinalText(paneId: string, cwd: string, sinceTs: number, attempts?: number): Promise<string | null>;
|
|
31
|
-
readAskFlag(paneId: string): Promise<string | null>;
|
|
32
|
-
probeAlive(paneId: string, cwd: string): Promise<AliveProbe>;
|
|
33
|
-
subSessionState(paneId: string, cwd: string, sinceTs: number): Promise<SubSessionState>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Cheap change fingerprint of a session file; null when it does not exist. */
|
|
37
|
-
interface FileStamp {
|
|
38
|
-
size: number;
|
|
39
|
-
mtimeMs: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function stampOf(file: string): FileStamp | null {
|
|
43
|
-
try {
|
|
44
|
-
const s = statSync(file);
|
|
45
|
-
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Readable regular file check: parsing the file would read it in full (up to MBs). */
|
|
52
|
-
function isReadableFile(file: string): boolean {
|
|
53
|
-
try {
|
|
54
|
-
if (!statSync(file).isFile()) return false;
|
|
55
|
-
accessSync(file, constants.R_OK);
|
|
56
|
-
return true;
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Memo of values derived from a session file, keyed by path and invalidated by (size, mtime).
|
|
64
|
-
*
|
|
65
|
-
* Why: worker sessions grow to tens of MB, and the poll loop re-derives the same idle state every
|
|
66
|
-
* tick (collectFinalText re-derives up to `attempts` times). Measured on a 31.6MB session:
|
|
67
|
-
* file read + JSON.parse + derive ≈ 227ms, so an unchanged file must not be parsed twice.
|
|
68
|
-
* An append-only JSONL changes size on every write, so (size, mtime) is a sound fingerprint.
|
|
69
|
-
*/
|
|
70
|
-
class DerivedCache<T> {
|
|
71
|
-
private readonly entries = new Map<string, { stamp: FileStamp; sinceTs: number; value: T }>();
|
|
72
|
-
private readonly limit: number;
|
|
73
|
-
|
|
74
|
-
constructor(limit = 32) {
|
|
75
|
-
this.limit = limit;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
get(file: string, stamp: FileStamp, sinceTs: number): T | undefined {
|
|
79
|
-
const hit = this.entries.get(file);
|
|
80
|
-
if (!hit || hit.stamp.size !== stamp.size || hit.stamp.mtimeMs !== stamp.mtimeMs || hit.sinceTs !== sinceTs) {
|
|
81
|
-
return undefined;
|
|
82
|
-
}
|
|
83
|
-
return hit.value;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
set(file: string, stamp: FileStamp, sinceTs: number, value: T): void {
|
|
87
|
-
this.entries.delete(file);
|
|
88
|
-
this.entries.set(file, { stamp, sinceTs, value });
|
|
89
|
-
while (this.entries.size > this.limit) {
|
|
90
|
-
const oldest = this.entries.keys().next().value;
|
|
91
|
-
if (oldest === undefined) break;
|
|
92
|
-
this.entries.delete(oldest);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function createSessionIo(h: SessionIoHost): SessionIo {
|
|
98
|
-
const stateCache = new DerivedCache<SubSessionState>();
|
|
99
|
-
const finalTextCache = new DerivedCache<string | null>();
|
|
100
|
-
|
|
101
|
-
async function resolveSessionFileCandidates(paneId: string, cwd: string): Promise<string[]> {
|
|
102
|
-
const out: string[] = [];
|
|
103
|
-
try {
|
|
104
|
-
const reported = await h.client.getAgentSessionPath(paneId);
|
|
105
|
-
if (reported) {
|
|
106
|
-
if (/\.jsonl$/.test(reported)) out.push(reported);
|
|
107
|
-
else {
|
|
108
|
-
const byId = sessionFileById(cwd, h.sessionsDir(), reported);
|
|
109
|
-
if (byId) out.push(byId);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
} catch {
|
|
113
|
-
/* Reports may be unavailable during startup. */
|
|
114
|
-
}
|
|
115
|
-
const ownSession = h.getSessionId();
|
|
116
|
-
for (const f of listSessionFiles(cwd, h.sessionsDir(), 4)) {
|
|
117
|
-
if (f !== ownSession && !out.includes(f)) out.push(f);
|
|
118
|
-
}
|
|
119
|
-
return out;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function resolveSessionFile(paneId: string, cwd: string): Promise<string | null> {
|
|
123
|
-
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
124
|
-
if (isReadableFile(file)) return file;
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async function collectFinalText(
|
|
130
|
-
paneId: string,
|
|
131
|
-
cwd: string,
|
|
132
|
-
sinceTs: number,
|
|
133
|
-
attempts = 12,
|
|
134
|
-
): Promise<string | null> {
|
|
135
|
-
for (let i = 0; i < attempts; i++) {
|
|
136
|
-
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
137
|
-
const stamp = stampOf(file);
|
|
138
|
-
if (!stamp) continue;
|
|
139
|
-
const cached = finalTextCache.get(file, stamp, sinceTs);
|
|
140
|
-
if (cached !== undefined) {
|
|
141
|
-
if (cached) return cached;
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
const entries: SessionEntryLike[] | null = readSessionFile(file);
|
|
145
|
-
if (!entries) continue;
|
|
146
|
-
const text = lastAssistantText(entries, { sinceTs })?.text ?? null;
|
|
147
|
-
finalTextCache.set(file, stamp, sinceTs, text);
|
|
148
|
-
if (text) return text;
|
|
149
|
-
}
|
|
150
|
-
const wait = Promise.withResolvers<void>();
|
|
151
|
-
setTimeout(wait.resolve, 500);
|
|
152
|
-
await wait.promise;
|
|
153
|
-
}
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function readAskFlag(paneId: string): Promise<string | null> {
|
|
158
|
-
try {
|
|
159
|
-
const a = (await h.client.listAgents()).find((x) => x.paneId === paneId);
|
|
160
|
-
const v = a?.tokens?.['pi-ask'];
|
|
161
|
-
return typeof v === 'string' && v ? v : null;
|
|
162
|
-
} catch {
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
|
|
168
|
-
const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
|
|
169
|
-
let listed = false;
|
|
170
|
-
try {
|
|
171
|
-
const pane = (await h.client.listPanes()).find((p) => p.paneId === paneId);
|
|
172
|
-
listed = true;
|
|
173
|
-
probe.paneExists = pane != null;
|
|
174
|
-
probe.agentStatus = pane?.agentStatus ?? null;
|
|
175
|
-
if (pane?.foregroundCwd) probe.foregroundCwd = pane.foregroundCwd;
|
|
176
|
-
} catch {
|
|
177
|
-
/* pane.list unavailable — agent.list is a degraded fallback, not a death signal */
|
|
178
|
-
}
|
|
179
|
-
try {
|
|
180
|
-
const a = (await h.client.listAgents()).find((x) => x.paneId === paneId);
|
|
181
|
-
if (a) {
|
|
182
|
-
if (!listed) probe.paneExists = true;
|
|
183
|
-
probe.agentStatus = a.status ?? probe.agentStatus;
|
|
184
|
-
if (a.foregroundCwd) probe.foregroundCwd = a.foregroundCwd;
|
|
185
|
-
}
|
|
186
|
-
} catch {
|
|
187
|
-
/* agent.list is status-only; absence is not death */
|
|
188
|
-
}
|
|
189
|
-
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
190
|
-
const stamp = stampOf(file);
|
|
191
|
-
// Session files can disappear during candidate scanning; a vanished file has no activity.
|
|
192
|
-
if (!stamp) continue;
|
|
193
|
-
if (probe.lastActivityMs == null || stamp.mtimeMs > probe.lastActivityMs) probe.lastActivityMs = stamp.mtimeMs;
|
|
194
|
-
}
|
|
195
|
-
return probe;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function subSessionState(
|
|
199
|
-
paneId: string,
|
|
200
|
-
cwd: string,
|
|
201
|
-
sinceTs: number,
|
|
202
|
-
): Promise<SubSessionState> {
|
|
203
|
-
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
204
|
-
const stamp = stampOf(file);
|
|
205
|
-
// A missing or vanished path is skipped: herdr often reports a .jsonl path before the worker
|
|
206
|
-
// creates the file, and waitAgent(idle) returns immediately, which used to throw
|
|
207
|
-
// `Cannot read properties of null (reading 'length')` (session 01a055c5).
|
|
208
|
-
if (!stamp) continue;
|
|
209
|
-
const cached = stateCache.get(file, stamp, sinceTs);
|
|
210
|
-
if (cached) return cached;
|
|
211
|
-
const entries = readSessionFile(file);
|
|
212
|
-
// An empty/unparsable file has no state to cache; the next candidate may still have one.
|
|
213
|
-
if (!entries?.length) continue;
|
|
214
|
-
const state = deriveSubSessionState(entries, sinceTs);
|
|
215
|
-
stateCache.set(file, stamp, sinceTs, state);
|
|
216
|
-
return state;
|
|
217
|
-
}
|
|
218
|
-
return { text: null, pendingTool: false, activity: false, turnEnded: false };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return {
|
|
222
|
-
resolveSessionFileCandidates,
|
|
223
|
-
resolveSessionFile,
|
|
224
|
-
collectFinalText,
|
|
225
|
-
readAskFlag,
|
|
226
|
-
probeAlive,
|
|
227
|
-
subSessionState,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|