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.
Files changed (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -0,0 +1,119 @@
1
+ /**
2
+ * P0 (RFC docs/rfc-pi-0.86-dynamic-tools.md §4): mutable role state for mid-session switching. pi
3
+ * 0.86 records `setActiveTools` deltas in the transcript, so a switch survives resume and branch
4
+ * navigation; this module owns pier's half — replaying the gate manifest from the last
5
+ * `pi-herdr.role-manifest` entry and planning the active-set transition.
6
+ */
7
+ import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
8
+ import type { RuntimeRoleManifest } from './tool-gate.ts';
9
+ import { filterToolsByStance } from './tool-gate.ts';
10
+
11
+ /** Shape persisted in ROLE_MANIFEST_CUSTOM_TYPE entries: the runtime manifest plus the payload version and
12
+ * the switch provenance. `version` is the payload discriminator and `manifestVersion` the manifest semver
13
+ * — replay must not confuse the two. */
14
+ export type RoleManifestRecord = Omit<RuntimeRoleManifest, 'version'> & {
15
+ version: 1;
16
+ manifestVersion?: string;
17
+ /** 'switch' records a mid-session switch; the session_start anchor write omits it (env origin). */
18
+ origin?: 'switch';
19
+ switchedBy?: string;
20
+ ts?: number;
21
+ };
22
+
23
+ /** State carried across a process for the armed role system; manifest==null means unarmed (bare pi). */
24
+ export interface RoleState {
25
+ manifest: RuntimeRoleManifest | null;
26
+ origin: 'env' | 'switch';
27
+ switchedBy: string | null;
28
+ switchedAt: number | null;
29
+ }
30
+
31
+ export function initialRoleState(manifest: RuntimeRoleManifest | null): RoleState {
32
+ return { manifest, origin: 'env', switchedBy: null, switchedAt: null };
33
+ }
34
+
35
+ /** Last `pi-herdr.role-manifest` record on the branch, or null. Session entries are pi JSONL objects
36
+ * ({ type: 'custom', customType, data }); unknown shapes are skipped, not fatal — replay must never break
37
+ * session startup. */
38
+ export function latestRoleManifestRecord(entries: readonly unknown[]): RoleManifestRecord | null {
39
+ let found: RoleManifestRecord | null = null;
40
+ for (const raw of entries) {
41
+ const entry = raw as { type?: unknown; customType?: unknown; data?: unknown };
42
+ if (entry?.type !== 'custom' || entry.customType !== 'pi-herdr.role-manifest') continue;
43
+ const data = entry.data as Partial<RoleManifestRecord> | undefined;
44
+ if (
45
+ !data ||
46
+ typeof data.role !== 'string' ||
47
+ !Array.isArray(data.tools) ||
48
+ !data.tools.every((t) => typeof t === 'string')
49
+ ) {
50
+ continue;
51
+ }
52
+ const guidelines = Array.isArray(data.guidelines)
53
+ ? data.guidelines.filter((g): g is string => typeof g === 'string')
54
+ : [];
55
+ const rawPermissions = data.permissions;
56
+ found = {
57
+ version: 1,
58
+ role: data.role,
59
+ manifestVersion: typeof data.manifestVersion === 'string' ? data.manifestVersion : undefined,
60
+ tools: data.tools as string[],
61
+ permissions:
62
+ rawPermissions && typeof rawPermissions === 'object' && !Array.isArray(rawPermissions)
63
+ ? (rawPermissions as RuntimeRoleManifest['permissions'])
64
+ : {},
65
+ unknownTools: data.unknownTools === 'allow' ? 'allow' : 'deny',
66
+ guidelines: guidelines.length > 0 ? guidelines : undefined,
67
+ origin: data.origin === 'switch' ? 'switch' : undefined,
68
+ switchedBy: typeof data.switchedBy === 'string' ? data.switchedBy : undefined,
69
+ ts: typeof data.ts === 'number' ? data.ts : undefined,
70
+ };
71
+ }
72
+ return found;
73
+ }
74
+
75
+ export function manifestFromRecord(rec: RoleManifestRecord): RuntimeRoleManifest {
76
+ return {
77
+ role: rec.role,
78
+ version: rec.manifestVersion,
79
+ tools: rec.tools,
80
+ permissions: rec.permissions,
81
+ unknownTools: rec.unknownTools,
82
+ ...(rec.guidelines && rec.guidelines.length > 0 ? { guidelines: rec.guidelines } : {}),
83
+ };
84
+ }
85
+
86
+ /** Whether the record differs from the state that would be written now (drives change-only writes). */
87
+ export function roleRecordDiffers(rec: RoleManifestRecord | null, state: RoleState): boolean {
88
+ const m = state.manifest;
89
+ if (!m) return false;
90
+ if (!rec) return true;
91
+ return rec.role !== m.role || JSON.stringify(rec.tools) !== JSON.stringify(m.tools);
92
+ }
93
+
94
+ /** Switch plan: widening (needs human confirm when driven by /pier-role) + diff summary. */
95
+ export interface RoleSwitchPlan {
96
+ widening: boolean;
97
+ added: string[];
98
+ removed: string[];
99
+ }
100
+
101
+ export function planRoleSwitch(oldTools: readonly string[], newTools: readonly string[]): RoleSwitchPlan {
102
+ const oldSet = new Set(oldTools);
103
+ const newSet = new Set(newTools);
104
+ const added = newTools.filter((t) => !oldSet.has(t));
105
+ const removed = oldTools.filter((t) => !newSet.has(t));
106
+ return { widening: added.length > 0, added, removed };
107
+ }
108
+
109
+ /** Active set for a switch, over ALL registered tools rather than the current active set: a switch may
110
+ * re-admit tools an earlier session_start prune removed, which intersection semantics would lose. Stance
111
+ * handling is shared with planActiveTools (tool-gate.ts); the empty result is NOT a no-op here — a switch
112
+ * to a role with no overlap is a legitimate shrink. */
113
+ export function planSwitchActiveTools(
114
+ manifestTools: readonly string[],
115
+ registeredToolNames: readonly string[],
116
+ opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
117
+ ): string[] {
118
+ return filterToolsByStance(manifestTools, registeredToolNames, opts);
119
+ }
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "role": "worker-default",
3
- "version": "1.4.0",
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
  ],
@@ -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
+ }
@@ -1,63 +1,48 @@
1
1
  /**
2
- * Centralized runtime policy and timeouts.
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 (ms) */
11
+ /** Settlement notice window / machine-inject grace / takeover idle */
17
12
  readonly settlementWindowMs: number
18
- /** Post-settle observation window before auto-consume (ms) */
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 (ms) */
18
+ /** Subagent pane pipe readiness wait */
27
19
  readonly readinessTimeoutMs: number
28
20
  }
29
21
 
30
- /** B10: read through the option catalog so the legacy `PI_HERDR_*` spelling keeps working. */
31
- function parseEnvInt(key: string, defaultValue: number, min: number = 0): number {
32
- const raw = pierOption(key)
33
- if (!raw) return defaultValue
34
-
35
- const parsed = Number.parseInt(raw, 10)
36
- if (!Number.isFinite(parsed) || parsed < min) {
37
- console.warn(`Invalid ${key}="${raw}", using default ${defaultValue}`)
38
- return defaultValue
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
- return {
46
- subagentTimeoutMs: overrides?.subagentTimeoutMs ?? parseEnvInt('PIER_SUBAGENT_TIMEOUT_MS', 600_000, 1000),
47
- gcTickMs: overrides?.gcTickMs ?? parseEnvInt('PIER_GC_TICK_MS', 30_000, 1000),
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()
@@ -1,15 +1,13 @@
1
1
  /**
2
- * Tail parsing for pi session JSONL (pure library, v1.1 result channel).
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
- * Return the latest finalized assistant text (stopReason === 'stop'), excluding toolUse intermediates.
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
- * Return whether the NEWEST assistant message after `sinceTs` ended its turn.
95
- *
96
- * Why (A16): a worker sitting between tool calls (tool result already written, next assistant
97
- * message still streaming) has "an assistant message" but has NOT finished. Treating any assistant
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
- * Return whether an initiated toolCall still lacks a result after injection (for example,
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
- * Derive the settlement state from already-parsed entries.
156
- *
157
- * Closing text is by construction terminal (lastAssistantText requires stopReason 'stop').
158
- * A16: an assistant message alone is not a settlement; the turn must have ENDED.
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
- * Locate a session file by id (`<ts>_<id>.jsonl`).
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'));
@@ -1,16 +1,8 @@
1
1
  /**
2
- * Pure core for the settled-wake decision (prevents wake storms; evidenced by session 01a03bf0).
3
- *
4
- * Evidence chain: ESC abort → agent_settled → D96 "background subagents still running" notice
5
- * → sendUserMessage(followUp) (always starts a new run while idle) → master restarts → user presses ESC again → …
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,86 @@ 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 before this settlement (unknown = null, treated as natural completion). */
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). */
18
+ /**
19
+ * pi ≥ 1.1 carries `aborted` on agent_settled itself: authoritative for runs cancelled without a
20
+ * final assistant message (ESC mid-tool), where stopReason tracking sees a stale natural reason.
21
+ */
22
+ aborted?: boolean;
27
23
  running: ReadonlyArray<{ paneId: string }>;
28
- /** Set key recorded for the previous D96 notice (none = null). */
29
24
  lastNoticeKey: string | null;
30
- /** Timestamp of the previous D96 notice (epoch ms). */
31
25
  lastNoticeAt: number;
32
26
  now: number;
27
+ /**
28
+ * A compaction abort is already selected. turn_start clears intentionalAbort before agent_settled
29
+ * reads it, so this is the durable signal: do not inject a running-sub notice that compaction will abort.
30
+ */
31
+ compactionPending?: boolean;
33
32
  }
34
33
 
35
34
  export interface SettleWakePlan {
36
35
  /** false means this settlement stays silent and emits no wake-up message. */
37
36
  wake: boolean;
38
- /** Whether to inject the D96 "still running" notice. */
37
+ /**
38
+ * Hand the settlement to the compaction coordinator. An OCC abort is silent (no wake) yet is the
39
+ * very settlement the selected compaction waits for; only a foreign (ESC) abort skips it.
40
+ */
41
+ compact: boolean;
39
42
  notice: boolean;
40
- /** New set key (updated when notifying; empty running set → null). */
43
+ /** New set key; empty running set → null. */
41
44
  noticeKey: string | null;
42
45
  noticeAt: number;
43
46
  }
44
47
 
45
48
  export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
46
- if (input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
47
- return { wake: false, notice: false, noticeKey: input.lastNoticeKey, noticeAt: input.lastNoticeAt };
49
+ if (input.compactionPending) {
50
+ return {
51
+ wake: false,
52
+ compact: true,
53
+ notice: false,
54
+ noticeKey: input.lastNoticeKey,
55
+ noticeAt: input.lastNoticeAt,
56
+ };
57
+ }
58
+ if (input.aborted === true || input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
59
+ return {
60
+ wake: false,
61
+ compact: input.intentionalAbort === true,
62
+ notice: false,
63
+ noticeKey: input.lastNoticeKey,
64
+ noticeAt: input.lastNoticeAt,
65
+ };
48
66
  }
49
67
  const key = input.running.length === 0 ? null : input.running.map((s) => s.paneId).sort().join(',');
50
68
  if (key === null) {
51
- return { wake: true, notice: false, noticeKey: null, noticeAt: input.lastNoticeAt };
69
+ return { wake: true, compact: true, notice: false, noticeKey: null, noticeAt: input.lastNoticeAt };
52
70
  }
53
71
  const newSet = key !== input.lastNoticeKey;
54
72
  const cooled = input.now - input.lastNoticeAt >= D96_REPEAT_NOTICE_MS;
55
73
  const notice = newSet || cooled;
56
- return { wake: true, notice, noticeKey: key, noticeAt: notice ? input.now : input.lastNoticeAt };
74
+ return { wake: true, compact: true, notice, noticeKey: key, noticeAt: notice ? input.now : input.lastNoticeAt };
75
+ }
76
+
77
+ /**
78
+ * Whether a just-settled run may answer its pending machine request (the pipe settle fast-path).
79
+ * An OCC intentional abort and a still-running compaction both mean the continuation turn is
80
+ * coming: answering now reports "finished, no closing message" for live work and — worse —
81
+ * consumes the one-shot request plus the parent's claim latch, so the real closing message can
82
+ * never be delivered (observed: a worker mid-compaction announced finished; its final report
83
+ * never reached the parent). A user abort ('aborted') likewise settles a run that may re-run.
84
+ */
85
+ export function shouldPushSettleReply(input: {
86
+ intentionalAbort?: boolean;
87
+ /** pi ≥ 1.1 agent_settled.aborted: the run was cancelled — the closing message may still come after a re-run. */
88
+ aborted?: boolean;
89
+ compactionInFlight?: boolean;
90
+ lastStopReason: string | null;
91
+ }): boolean {
92
+ return !input.intentionalAbort
93
+ && input.aborted !== true
94
+ && !input.compactionInFlight
95
+ && input.lastStopReason !== ABORT_STOP_REASON;
57
96
  }