pi-pier 0.3.0 → 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.
- package/README.md +12 -11
- package/package.json +2 -2
- 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 +8 -12
- package/src/compact-coordinator.ts +278 -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/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- 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/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -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 +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- 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 +52 -21
- 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 +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -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/vocab.ts +52 -25
- 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/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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 0 routing telemetry (RFC docs/rfc-jev-role-routing.md §8): observation only, zero behavior
|
|
3
|
+
* change. Three row kinds answer the decision questions before the grants collapse (phase 1) and the
|
|
4
|
+
* jev router (phase 2) are built — spawn profile, deny hits, axis usage.
|
|
5
|
+
*
|
|
6
|
+
* Privacy contract: task text NEVER enters telemetry — only taskSha8 (correlatable, not reversible)
|
|
7
|
+
* and tool/role names. Planners are pure and sessionId-free; the central append (index.ts) stamps the
|
|
8
|
+
* session id once, and every append is best-effort.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
type SpawnProfileRecord = {
|
|
13
|
+
kind: 'spawn';
|
|
14
|
+
ts: number;
|
|
15
|
+
roleExplicit: boolean;
|
|
16
|
+
role: string;
|
|
17
|
+
/** Model-suggested extras from the spawn `allowed_tools` param. */
|
|
18
|
+
allowedTools: string[];
|
|
19
|
+
manifestTools: string[];
|
|
20
|
+
/** sha256(task)[0:8] — correlates spawns without persisting the task text. */
|
|
21
|
+
taskSha8: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type DenyHitRecord = {
|
|
25
|
+
kind: 'deny';
|
|
26
|
+
ts: number;
|
|
27
|
+
role: string;
|
|
28
|
+
tool: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type AxisUsageRecord = {
|
|
32
|
+
kind: 'axis-usage';
|
|
33
|
+
ts: number;
|
|
34
|
+
files: number;
|
|
35
|
+
parsed: number;
|
|
36
|
+
invalid: number;
|
|
37
|
+
askEntries: number;
|
|
38
|
+
stanceAllow: number;
|
|
39
|
+
stanceDeny: number;
|
|
40
|
+
/** [role, tool] pairs carrying an explicit deny — the clamp-table design data. */
|
|
41
|
+
explicitDenies: Array<[string, string]>;
|
|
42
|
+
};
|
|
43
|
+
/** sessionId-free: the central append stamps it into RoutingTelemetryRow. */
|
|
44
|
+
export type RoutingTelemetryRecord = SpawnProfileRecord | DenyHitRecord | AxisUsageRecord;
|
|
45
|
+
|
|
46
|
+
export type RoutingTelemetryRow =
|
|
47
|
+
| ({ sessionId: string } & SpawnProfileRecord)
|
|
48
|
+
| ({ sessionId: string } & DenyHitRecord)
|
|
49
|
+
| ({ sessionId: string } & AxisUsageRecord);
|
|
50
|
+
|
|
51
|
+
export function taskFingerprint(task: string): string {
|
|
52
|
+
return createHash('sha256').update(task).digest('hex').slice(0, 8);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function planSpawnProfileRow(opts: {
|
|
56
|
+
now: number;
|
|
57
|
+
roleExplicit: boolean;
|
|
58
|
+
role: string;
|
|
59
|
+
allowedTools: readonly string[];
|
|
60
|
+
manifestTools: readonly string[];
|
|
61
|
+
task: string;
|
|
62
|
+
}): SpawnProfileRecord {
|
|
63
|
+
return {
|
|
64
|
+
kind: 'spawn',
|
|
65
|
+
ts: opts.now,
|
|
66
|
+
roleExplicit: opts.roleExplicit,
|
|
67
|
+
role: opts.role,
|
|
68
|
+
allowedTools: [...opts.allowedTools],
|
|
69
|
+
manifestTools: [...opts.manifestTools],
|
|
70
|
+
taskSha8: taskFingerprint(opts.task),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function planDenyHitRow(opts: {
|
|
75
|
+
now: number;
|
|
76
|
+
role: string;
|
|
77
|
+
tool: string;
|
|
78
|
+
}): DenyHitRecord {
|
|
79
|
+
return { kind: 'deny', ts: opts.now, role: opts.role, tool: opts.tool };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fold role-file texts into the axis-usage summary. A malformed file counts as invalid and never
|
|
84
|
+
* fails the scan. Shapes are mirrored, not imported, so telemetry cannot break on schema drift.
|
|
85
|
+
*/
|
|
86
|
+
export function scanRoleAxisUsage(opts: {
|
|
87
|
+
now: number;
|
|
88
|
+
files: ReadonlyArray<{ name: string; text: string }>;
|
|
89
|
+
}): AxisUsageRecord {
|
|
90
|
+
let parsed = 0;
|
|
91
|
+
let invalid = 0;
|
|
92
|
+
let askEntries = 0;
|
|
93
|
+
let stanceAllow = 0;
|
|
94
|
+
let stanceDeny = 0;
|
|
95
|
+
const explicitDenies: Array<[string, string]> = [];
|
|
96
|
+
for (const file of opts.files) {
|
|
97
|
+
let doc: {
|
|
98
|
+
role?: unknown;
|
|
99
|
+
manifest?: { unknownTools?: unknown; rules?: Record<string, unknown> | undefined };
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
doc = JSON.parse(file.text) as typeof doc;
|
|
103
|
+
} catch {
|
|
104
|
+
invalid += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Schema contract: only role + manifest are required. `rules` may be omitted (implicit
|
|
108
|
+
// {"*":"allow"}) and unknownTools defaults to deny, so a minimal role still counts as parsed.
|
|
109
|
+
if (typeof doc.role !== 'string' || typeof doc.manifest !== 'object' || doc.manifest === null) {
|
|
110
|
+
invalid += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
parsed += 1;
|
|
114
|
+
if (doc.manifest.unknownTools === 'allow') stanceAllow += 1;
|
|
115
|
+
else stanceDeny += 1;
|
|
116
|
+
const rules =
|
|
117
|
+
typeof doc.manifest.rules === 'object' && doc.manifest.rules !== null
|
|
118
|
+
? doc.manifest.rules
|
|
119
|
+
: ({ '*': 'allow' } as Record<string, unknown>);
|
|
120
|
+
for (const [tool, action] of Object.entries(rules)) {
|
|
121
|
+
if (action === 'ask') askEntries += 1;
|
|
122
|
+
else if (action === 'deny' && tool !== '*') explicitDenies.push([doc.role, tool]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
kind: 'axis-usage',
|
|
127
|
+
ts: opts.now,
|
|
128
|
+
files: opts.files.length,
|
|
129
|
+
parsed,
|
|
130
|
+
invalid,
|
|
131
|
+
askEntries,
|
|
132
|
+
stanceAllow,
|
|
133
|
+
stanceDeny,
|
|
134
|
+
explicitDenies,
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/runtime-policy.ts
CHANGED
|
@@ -1,63 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Why: Prevents drift between hardcoded literals and environment overrides;
|
|
5
|
-
* makes timing behavior explicit and testable.
|
|
2
|
+
* Every field is fed by the `PIER_OPTIONS` registry, so the numbers cannot drift from
|
|
3
|
+
* `/pier-config doctor`; an out-of-range value warns and falls back to the registry default.
|
|
6
4
|
*/
|
|
7
|
-
import { pierOption } from './pier-options.ts';
|
|
5
|
+
import { PIER_OPTIONS, pierOption, type PolicyField } from './pier-options.ts';
|
|
8
6
|
|
|
9
7
|
export interface RuntimePolicy {
|
|
10
|
-
/** Subagent overall timeout (ms) */
|
|
11
8
|
readonly subagentTimeoutMs: number
|
|
12
|
-
/** Subagent GC tick interval (ms) */
|
|
13
9
|
readonly gcTickMs: number
|
|
14
|
-
/** Poll interval for subagent state observation (ms) */
|
|
15
10
|
readonly pollIntervalMs: number
|
|
16
|
-
/** Settlement notice window / machine-inject grace / takeover idle
|
|
11
|
+
/** Settlement notice window / machine-inject grace / takeover idle */
|
|
17
12
|
readonly settlementWindowMs: number
|
|
18
|
-
/** Post-settle observation window before auto-consume
|
|
13
|
+
/** Post-settle observation window before auto-consume */
|
|
19
14
|
readonly observationWindowMs: number
|
|
20
|
-
/** Foreground patience before background promotion (ms) */
|
|
21
15
|
readonly foregroundPatienceMs: number
|
|
22
|
-
/** Default session TTL (seconds) */
|
|
23
16
|
readonly sessionTtlSeconds: number
|
|
24
|
-
/** Git operation timeout (ms) */
|
|
25
17
|
readonly gitTimeoutMs: number
|
|
26
|
-
/** Subagent pane pipe readiness wait
|
|
18
|
+
/** Subagent pane pipe readiness wait */
|
|
27
19
|
readonly readinessTimeoutMs: number
|
|
28
20
|
}
|
|
29
21
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
22
|
+
function policyValue(field: PolicyField): number {
|
|
23
|
+
const spec = PIER_OPTIONS.find((o) => o.policy === field);
|
|
24
|
+
if (!spec) throw new Error(`no PIER_OPTIONS entry feeds RuntimePolicy.${field}`);
|
|
25
|
+
const fallback = Number(spec.fallback);
|
|
26
|
+
const raw = pierOption(spec.name);
|
|
27
|
+
if (raw === undefined) return fallback;
|
|
28
|
+
const parsed = Number.parseInt(raw, 10);
|
|
29
|
+
if (!Number.isFinite(parsed) || parsed < (spec.min ?? 0)) {
|
|
30
|
+
console.warn(`Invalid ${spec.name}="${raw}", using default ${spec.fallback}`);
|
|
31
|
+
return fallback;
|
|
39
32
|
}
|
|
40
|
-
|
|
41
|
-
return parsed
|
|
33
|
+
return parsed;
|
|
42
34
|
}
|
|
43
35
|
|
|
44
36
|
export function createRuntimePolicy(overrides?: Partial<RuntimePolicy>): RuntimePolicy {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
pollIntervalMs: overrides?.pollIntervalMs ?? parseEnvInt('PIER_POLL_INTERVAL_MS', 30_000, 1000),
|
|
49
|
-
settlementWindowMs: overrides?.settlementWindowMs ?? parseEnvInt('PIER_SETTLEMENT_WINDOW_MS', 60_000, 0),
|
|
50
|
-
observationWindowMs: overrides?.observationWindowMs ?? parseEnvInt('PIER_OBSERVATION_WINDOW_MS', 30_000, 0),
|
|
51
|
-
foregroundPatienceMs: overrides?.foregroundPatienceMs ?? parseEnvInt('PIER_FOREGROUND_PATIENCE_MS', 300_000, 0),
|
|
52
|
-
sessionTtlSeconds: overrides?.sessionTtlSeconds ?? parseEnvInt('PIER_SESSION_TTL_SECONDS', 600, 0),
|
|
53
|
-
gitTimeoutMs: overrides?.gitTimeoutMs ?? parseEnvInt('PIER_GIT_TIMEOUT_MS', 10_000, 1),
|
|
54
|
-
// A14: concurrent heavy workers can take far longer than 30s to boot; PIER_READY_TIMEOUT_MS overrides.
|
|
55
|
-
readinessTimeoutMs: overrides?.readinessTimeoutMs ?? parseEnvInt('PIER_READY_TIMEOUT_MS', 90_000, 1000),
|
|
37
|
+
const policy = {} as { -readonly [K in keyof RuntimePolicy]: number };
|
|
38
|
+
for (const spec of PIER_OPTIONS) {
|
|
39
|
+
if (spec.policy) policy[spec.policy] = policyValue(spec.policy);
|
|
56
40
|
}
|
|
41
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
42
|
+
if (typeof value === 'number') policy[key as keyof RuntimePolicy] = value;
|
|
43
|
+
}
|
|
44
|
+
return policy;
|
|
57
45
|
}
|
|
58
46
|
|
|
59
|
-
/**
|
|
60
|
-
* Singleton runtime policy for production use.
|
|
61
|
-
* Tests should inject via createRuntimePolicy().
|
|
62
|
-
*/
|
|
47
|
+
/** Singleton for production; tests inject via createRuntimePolicy(). */
|
|
63
48
|
export const runtimePolicy = createRuntimePolicy()
|
package/src/session-tail.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tail parsing for pi session JSONL (pure
|
|
3
|
-
*
|
|
4
|
-
* Since v1.1 (DESIGN.md §12), child-agent results come from the child session file
|
|
5
|
-
* rather than pane text, keeping the authoritative machine-readable source free of
|
|
6
|
-
* echo contamination and scrollback blind spots. This module depends only on the
|
|
7
|
-
* empirically observed pi session format (session-format documentation):
|
|
2
|
+
* Tail parsing for pi session JSONL (pure): child-agent results come from the child session file,
|
|
3
|
+
* not pane text. Depends only on the pi session format:
|
|
8
4
|
* line = {type, id, parentId, message:{role, content:[{type:'text',text}...], timestamp, stopReason}}
|
|
9
5
|
*/
|
|
10
6
|
import * as fs from 'node:fs';
|
|
11
7
|
import * as path from 'node:path';
|
|
12
8
|
import { piSessionDirCandidates, sessionDirName } from './storage-layout.ts';
|
|
9
|
+
import { isValidSessionId } from './efficiency-store.ts';
|
|
10
|
+
import { COMPACTION_INFLIGHT_TYPE, COMPACTION_SETTLED_TYPE } from './compact-coordinator.ts';
|
|
13
11
|
|
|
14
12
|
export { sessionDirName };
|
|
15
13
|
|
|
@@ -25,7 +23,6 @@ export interface SessionEntryLike {
|
|
|
25
23
|
message?: SessionMessageLike | unknown;
|
|
26
24
|
[k: string]: unknown;
|
|
27
25
|
}
|
|
28
|
-
/** Parse line by line tolerantly, skipping malformed or non-JSON records. */
|
|
29
26
|
export function parseSessionEntries(text: string): SessionEntryLike[] {
|
|
30
27
|
const entries: SessionEntryLike[] = [];
|
|
31
28
|
for (const line of (text ?? '').split(/\r?\n/)) {
|
|
@@ -35,7 +32,6 @@ export function parseSessionEntries(text: string): SessionEntryLike[] {
|
|
|
35
32
|
const obj = JSON.parse(t);
|
|
36
33
|
if (obj && typeof obj === 'object') entries.push(obj as SessionEntryLike);
|
|
37
34
|
} catch {
|
|
38
|
-
/* Skip truncated or malformed records. */
|
|
39
35
|
}
|
|
40
36
|
}
|
|
41
37
|
return entries;
|
|
@@ -61,10 +57,8 @@ function textOf(m: SessionMessageLike): string {
|
|
|
61
57
|
.join('\n')
|
|
62
58
|
.trim();
|
|
63
59
|
}
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
* sinceTs filters out messages that predate the injection point.
|
|
67
|
-
*/
|
|
60
|
+
/** Latest finalized assistant text (`stopReason === 'stop'`), excluding toolUse intermediates;
|
|
61
|
+
* `sinceTs` filters out messages predating the injection point. */
|
|
68
62
|
export function lastAssistantText(
|
|
69
63
|
entries: readonly SessionEntryLike[],
|
|
70
64
|
opts: { sinceTs?: number } = {},
|
|
@@ -91,16 +85,10 @@ export function hasAssistantAfter(
|
|
|
91
85
|
});
|
|
92
86
|
}
|
|
93
87
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* message as activity made the poller announce such workers as finished — three real workers were
|
|
99
|
-
* declared settled while still working on 2026-09-13 and one was later closed mid-task by GC.
|
|
100
|
-
*
|
|
101
|
-
* `stopReason === 'toolUse'` means the model requested tools, so the turn continues; a non-string
|
|
102
|
-
* stopReason (message still streaming / older shapes) is treated as "not ended" on purpose — the
|
|
103
|
-
* cost of waiting is small, the cost of a false settlement is losing supervision of live work.
|
|
88
|
+
* Whether the NEWEST assistant message after `sinceTs` ended its turn: a worker between tool calls
|
|
89
|
+
* has "an assistant message" but has NOT finished, so announcing it settled loses supervision of
|
|
90
|
+
* live work. `stopReason === 'toolUse'` means tools were requested; a non-string stopReason (still
|
|
91
|
+
* streaming / older shapes) counts as "not ended" on purpose.
|
|
104
92
|
*/
|
|
105
93
|
export function lastAssistantTurnEnded(
|
|
106
94
|
entries: readonly SessionEntryLike[],
|
|
@@ -118,11 +106,8 @@ export function lastAssistantTurnEnded(
|
|
|
118
106
|
const sr = newest.stopReason;
|
|
119
107
|
return typeof sr === 'string' && sr !== 'toolUse' && sr !== 'pending';
|
|
120
108
|
}
|
|
121
|
-
/**
|
|
122
|
-
*
|
|
123
|
-
* ask_user_question waiting on human input), meaning settlement has not actually completed
|
|
124
|
-
* (v1.3 M8 settlement-race fix). Parallel calls are tracked by depth.
|
|
125
|
-
*/
|
|
109
|
+
/** Whether an initiated toolCall still lacks a result after injection (e.g. ask_user_question
|
|
110
|
+
* waiting on a human), meaning settlement has not completed. */
|
|
126
111
|
export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs: number): boolean {
|
|
127
112
|
let depth = 0;
|
|
128
113
|
for (const entry of entries) {
|
|
@@ -141,33 +126,56 @@ export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs
|
|
|
141
126
|
return depth > 0;
|
|
142
127
|
}
|
|
143
128
|
|
|
144
|
-
|
|
145
|
-
|
|
146
129
|
/** Derived settlement state of one child session, from the inject point onward. */
|
|
147
130
|
export interface SubSessionState {
|
|
148
131
|
text: string | null;
|
|
149
132
|
pendingTool: boolean;
|
|
150
133
|
activity: boolean;
|
|
151
134
|
turnEnded: boolean;
|
|
135
|
+
/** True while the child is inside an OCC compaction cycle: from the inflight marker until an
|
|
136
|
+
* assistant message follows the settled marker. OCC's intentional abort lands as stopReason
|
|
137
|
+
* 'error', which the turnEnded check alone would misread as finished. */
|
|
138
|
+
compacting: boolean;
|
|
152
139
|
}
|
|
153
140
|
|
|
154
141
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
142
|
+
* Whether the transcript currently sits inside an OCC compaction cycle. Last marker wins: inflight
|
|
143
|
+
* last means the summary request is running; settled last means the cycle is over — unless no
|
|
144
|
+
* assistant message follows it yet, in which case the worker is still machine-paused (not settled,
|
|
145
|
+
* not a user takeover). A cancelled cycle sends no continuation, so it is over at the marker.
|
|
159
146
|
*/
|
|
147
|
+
export function compactionBusy(entries: readonly SessionEntryLike[]): boolean {
|
|
148
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
149
|
+
const entry = entries[i];
|
|
150
|
+
if (!entry || entry.type !== 'custom') continue;
|
|
151
|
+
const customType = entry.customType;
|
|
152
|
+
if (customType === COMPACTION_INFLIGHT_TYPE) return true;
|
|
153
|
+
if (customType === COMPACTION_SETTLED_TYPE) {
|
|
154
|
+
// A cancel sends no continuation turn, so waiting for one would pin the worker as busy forever.
|
|
155
|
+
if ((entry.data as { outcome?: unknown } | undefined)?.outcome === 'cancelled') return false;
|
|
156
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
157
|
+
if (messageOf(entries[j])?.role === 'assistant') return false;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Closing text is terminal by construction (lastAssistantText requires stopReason 'stop'); an
|
|
166
|
+
* assistant message alone is not a settlement — the turn must have ENDED. */
|
|
160
167
|
export function deriveSubSessionState(
|
|
161
168
|
entries: readonly SessionEntryLike[],
|
|
162
169
|
sinceTs: number,
|
|
163
170
|
): SubSessionState {
|
|
171
|
+
const compacting = compactionBusy(entries);
|
|
164
172
|
const r = lastAssistantText(entries, { sinceTs });
|
|
165
|
-
if (r?.text) return { text: r.text, pendingTool: false, activity: true, turnEnded: true };
|
|
166
|
-
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true, turnEnded: false };
|
|
173
|
+
if (r?.text) return { text: r.text, pendingTool: false, activity: true, turnEnded: true, compacting };
|
|
174
|
+
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true, turnEnded: false, compacting };
|
|
167
175
|
if (hasAssistantAfter(entries, sinceTs)) {
|
|
168
|
-
return { text: null, pendingTool: false, activity: true, turnEnded: lastAssistantTurnEnded(entries, sinceTs) };
|
|
176
|
+
return { text: null, pendingTool: false, activity: true, turnEnded: lastAssistantTurnEnded(entries, sinceTs), compacting };
|
|
169
177
|
}
|
|
170
|
-
return { text: null, pendingTool: false, activity: false, turnEnded: false };
|
|
178
|
+
return { text: null, pendingTool: false, activity: false, turnEnded: false, compacting };
|
|
171
179
|
}
|
|
172
180
|
|
|
173
181
|
/** Newest `limit` session files under cwd's session dir (pi core's name first, then pier's old encodings). */
|
|
@@ -195,10 +203,8 @@ export function listSessionFiles(cwd: string, agentDir: string, limit = 4): stri
|
|
|
195
203
|
return files.slice(0, limit).map((f) => f.file);
|
|
196
204
|
}
|
|
197
205
|
|
|
198
|
-
/**
|
|
199
|
-
*
|
|
200
|
-
* Searches pi core's own directory name first, then pier's legacy flattened dirs.
|
|
201
|
-
*/
|
|
206
|
+
/** Locate a session file by id (`<ts>_<id>.jsonl`): pi core's directory name first, then pier's
|
|
207
|
+
* legacy flattened dirs. */
|
|
202
208
|
export function sessionFileById(cwd: string, agentDir: string, id: string): string | null {
|
|
203
209
|
const suffix = `_${id}.jsonl`;
|
|
204
210
|
for (const name of piSessionDirCandidates(cwd)) {
|
|
@@ -216,6 +222,24 @@ export function sessionFileById(cwd: string, agentDir: string, id: string): stri
|
|
|
216
222
|
return null;
|
|
217
223
|
}
|
|
218
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Normalize a session path or id to the bare id used across herdr-pi state (`<timestamp>_<uuid>.jsonl`
|
|
227
|
+
* → `<uuid>`; `sub-session.jsonl` stays whole). Only pi's real transcript prefix is stripped, so
|
|
228
|
+
* arbitrary ids like PI_SESSION_FILE=sess_idx_prune survive untouched. Same-normalization comparisons
|
|
229
|
+
* (own session vs candidate paths) must go through here.
|
|
230
|
+
*/
|
|
231
|
+
export function bareSessionId(raw: string): string {
|
|
232
|
+
const base = raw.replaceAll('\\', '/').split('/').pop()!.replace(/\.jsonl$/, '');
|
|
233
|
+
const stripped = base.replace(/^\d{4}-\d{2}-\d{2}T[\d-]+Z_/, '');
|
|
234
|
+
return isValidSessionId(stripped) ? stripped : base;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Herdr reports session ids and paths interchangeably: accept a `.jsonl` path as-is, else map the id. */
|
|
238
|
+
export function resolveSessionFileValue(cwd: string, agentDir: string, value: string | null | undefined): string | null {
|
|
239
|
+
if (!value) return null;
|
|
240
|
+
return /\.jsonl$/i.test(value) ? value : sessionFileById(cwd, agentDir, value);
|
|
241
|
+
}
|
|
242
|
+
|
|
219
243
|
export function readSessionFile(file: string): SessionEntryLike[] | null {
|
|
220
244
|
try {
|
|
221
245
|
return parseSessionEntries(fs.readFileSync(file, 'utf8'));
|
package/src/settle-wake-core.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* After 30 aborts, ESC never breaks the cycle; only Ctrl+C stops the process.
|
|
7
|
-
* Natural settlement also forms a self-triggering loop: settle → notice → new run → settle → notice …
|
|
8
|
-
*
|
|
9
|
-
* Rules:
|
|
10
|
-
* - Abort suppression: when the previous assistant stopReason === 'aborted', this settled event injects no
|
|
11
|
-
* wake-up message (the user explicitly stopped it; settlement-buffered content waits for the next natural turn);
|
|
12
|
-
* - D96 deduplication: notify once for a given running paneId set; any set change (new subagent/settlement) resets it;
|
|
13
|
-
* - D96 cooldown: repeat a notice for the same set only after REPEAT_NOTICE_MS (otherwise the unchanged set is noise).
|
|
2
|
+
* Settled-wake decision (prevents wake storms): a settle wake that injects messages creates a
|
|
3
|
+
* self-triggering loop (settle → notice → new run → settle → …) that even ESC cannot break, so an
|
|
4
|
+
* aborted previous turn injects nothing and a running set is announced once, re-announced only after
|
|
5
|
+
* D96_REPEAT_NOTICE_MS or when the set changes.
|
|
14
6
|
*/
|
|
15
7
|
|
|
16
8
|
export const ABORT_STOP_REASON = 'aborted';
|
|
@@ -19,39 +11,78 @@ export const ABORT_STOP_REASON = 'aborted';
|
|
|
19
11
|
export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
|
|
20
12
|
|
|
21
13
|
export interface SettleWakeInput {
|
|
22
|
-
/** stopReason of the last assistant turn
|
|
14
|
+
/** stopReason of the last assistant turn (null = treated as natural completion). */
|
|
23
15
|
lastStopReason: string | null;
|
|
24
16
|
/** True when abort was triggered intentionally by internal mechanisms like OCC. */
|
|
25
17
|
intentionalAbort?: boolean;
|
|
26
|
-
/** Background subagents still running (empty means none). */
|
|
27
18
|
running: ReadonlyArray<{ paneId: string }>;
|
|
28
|
-
/** Set key recorded for the previous D96 notice (none = null). */
|
|
29
19
|
lastNoticeKey: string | null;
|
|
30
|
-
/** Timestamp of the previous D96 notice (epoch ms). */
|
|
31
20
|
lastNoticeAt: number;
|
|
32
21
|
now: number;
|
|
22
|
+
/**
|
|
23
|
+
* A compaction abort is already selected. turn_start clears intentionalAbort before agent_settled
|
|
24
|
+
* reads it, so this is the durable signal: do not inject a running-sub notice that compaction will abort.
|
|
25
|
+
*/
|
|
26
|
+
compactionPending?: boolean;
|
|
33
27
|
}
|
|
34
28
|
|
|
35
29
|
export interface SettleWakePlan {
|
|
36
30
|
/** false means this settlement stays silent and emits no wake-up message. */
|
|
37
31
|
wake: boolean;
|
|
38
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Hand the settlement to the compaction coordinator. An OCC abort is silent (no wake) yet is the
|
|
34
|
+
* very settlement the selected compaction waits for; only a foreign (ESC) abort skips it.
|
|
35
|
+
*/
|
|
36
|
+
compact: boolean;
|
|
39
37
|
notice: boolean;
|
|
40
|
-
/** New set key
|
|
38
|
+
/** New set key; empty running set → null. */
|
|
41
39
|
noticeKey: string | null;
|
|
42
40
|
noticeAt: number;
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
|
|
44
|
+
if (input.compactionPending) {
|
|
45
|
+
return {
|
|
46
|
+
wake: false,
|
|
47
|
+
compact: true,
|
|
48
|
+
notice: false,
|
|
49
|
+
noticeKey: input.lastNoticeKey,
|
|
50
|
+
noticeAt: input.lastNoticeAt,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
46
53
|
if (input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
|
|
47
|
-
return {
|
|
54
|
+
return {
|
|
55
|
+
wake: false,
|
|
56
|
+
compact: input.intentionalAbort === true,
|
|
57
|
+
notice: false,
|
|
58
|
+
noticeKey: input.lastNoticeKey,
|
|
59
|
+
noticeAt: input.lastNoticeAt,
|
|
60
|
+
};
|
|
48
61
|
}
|
|
49
62
|
const key = input.running.length === 0 ? null : input.running.map((s) => s.paneId).sort().join(',');
|
|
50
63
|
if (key === null) {
|
|
51
|
-
return { wake: true, notice: false, noticeKey: null, noticeAt: input.lastNoticeAt };
|
|
64
|
+
return { wake: true, compact: true, notice: false, noticeKey: null, noticeAt: input.lastNoticeAt };
|
|
52
65
|
}
|
|
53
66
|
const newSet = key !== input.lastNoticeKey;
|
|
54
67
|
const cooled = input.now - input.lastNoticeAt >= D96_REPEAT_NOTICE_MS;
|
|
55
68
|
const notice = newSet || cooled;
|
|
56
|
-
return { wake: true, notice, noticeKey: key, noticeAt: notice ? input.now : input.lastNoticeAt };
|
|
69
|
+
return { wake: true, compact: true, notice, noticeKey: key, noticeAt: notice ? input.now : input.lastNoticeAt };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a just-settled run may answer its pending machine request (the pipe settle fast-path).
|
|
74
|
+
* An OCC intentional abort and a still-running compaction both mean the continuation turn is
|
|
75
|
+
* coming: answering now reports "finished, no closing message" for live work and — worse —
|
|
76
|
+
* consumes the one-shot request plus the parent's claim latch, so the real closing message can
|
|
77
|
+
* never be delivered (observed: a worker mid-compaction announced finished; its final report
|
|
78
|
+
* never reached the parent). A user abort ('aborted') likewise settles a run that may re-run.
|
|
79
|
+
*/
|
|
80
|
+
export function shouldPushSettleReply(input: {
|
|
81
|
+
intentionalAbort?: boolean;
|
|
82
|
+
compactionInFlight?: boolean;
|
|
83
|
+
lastStopReason: string | null;
|
|
84
|
+
}): boolean {
|
|
85
|
+
return !input.intentionalAbort
|
|
86
|
+
&& !input.compactionInFlight
|
|
87
|
+
&& input.lastStopReason !== ABORT_STOP_REASON;
|
|
57
88
|
}
|