pi-pier 0.1.5 → 0.2.0
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/package.json +1 -1
- package/schemas/role-manifest.schema.json +1 -2
- package/src/bootstrap.ts +18 -22
- package/src/core/grid-shape.ts +13 -14
- package/src/core/subagent.ts +325 -857
- package/src/core/terminal.ts +25 -28
- package/src/core/todo.ts +47 -53
- package/src/gc-core.ts +15 -15
- package/src/git-adapter.ts +104 -0
- package/src/herdr-client.ts +76 -68
- package/src/history-store.ts +70 -36
- package/src/index-locks.ts +105 -0
- package/src/index-master.ts +113 -0
- package/src/index-notices.ts +43 -0
- package/src/index-pipe.ts +73 -0
- package/src/index-runtime.ts +23 -0
- package/src/index-worker.ts +39 -0
- package/src/index.ts +134 -386
- package/src/ledger.ts +15 -17
- package/src/lock-core.ts +15 -29
- package/src/manifest-compose.ts +9 -23
- package/src/notice-buffer.ts +9 -14
- package/src/pane-title.ts +24 -24
- package/src/pi-surface.ts +22 -29
- package/src/pipe-channel.ts +67 -34
- package/src/platform-paths.ts +72 -0
- package/src/progress-core.ts +26 -24
- package/src/reconcile-core.ts +25 -24
- package/src/role-loader.ts +32 -35
- package/src/role-manifest.ts +24 -28
- package/src/roles/master.json +2 -2
- package/src/roles/worker-default.json +3 -3
- package/src/runtime-policy.ts +66 -0
- package/src/session-tail.ts +46 -45
- package/src/settle-wake-core.ts +19 -18
- package/src/slim-frame.ts +45 -44
- package/src/stale-core.ts +23 -21
- package/src/storage-layout.ts +80 -0
- package/src/subagent-core.ts +151 -94
- package/src/subagent-gc.ts +237 -0
- package/src/subagent-git-io.ts +80 -0
- package/src/subagent-launch.ts +110 -0
- package/src/subagent-poll-loop.ts +285 -0
- package/src/subagent-poller.ts +90 -0
- package/src/subagent-port.ts +27 -0
- package/src/subagent-scope.ts +4 -4
- package/src/subagent-session-io.ts +143 -0
- package/src/subagent-spawn.ts +156 -0
- package/src/terminal-core.ts +56 -54
- package/src/todo-core.ts +27 -64
- package/src/todo-read-hook.ts +28 -24
- package/src/todo-reminder-core.ts +89 -0
- package/src/todos-service.ts +36 -25
- package/src/tool-gate.ts +7 -28
- package/src/vocab.ts +18 -19
package/src/role-manifest.ts
CHANGED
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Layer 2 Week 1: role manifest types and validator (D76 v2; 2026-08-18).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Mirrors the contract in `schemas/role-manifest.schema.json` (the JSON Schema is the contract document;
|
|
5
|
+
* runtime validation is hand-written with zero dependencies so workers carry no ajv overhead).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Strict contract: report unknown top-level keys to prevent typos such as "rulez" from silently failing;
|
|
8
|
+
* collect all errors in one pass so manifests can be repaired efficiently.
|
|
9
9
|
*/
|
|
10
10
|
export type PermissionAction = 'allow' | 'ask' | 'deny';
|
|
11
11
|
export type TodosMode = 'serial' | 'parallel';
|
|
12
|
-
/** D82
|
|
12
|
+
/** D82: stance for unknown tools—visibility is a trust relationship (user→master via install; master→delegated via mirror). */
|
|
13
13
|
export type UnknownToolStance = 'allow' | 'deny';
|
|
14
14
|
|
|
15
15
|
export interface RoleManifest {
|
|
16
16
|
role: string;
|
|
17
|
-
/**
|
|
17
|
+
/** Semantic version x.y.z (for tracing manifest evolution; P2). */
|
|
18
18
|
version: string;
|
|
19
19
|
/**
|
|
20
|
-
* WS-D10
|
|
21
|
-
*
|
|
20
|
+
* WS-D10: route models by role using `provider/model` (advisor delegates higher-level third-party judgment).
|
|
21
|
+
* Omitted means follow the process default (master/worker leave it unset); spawn injects `--provider/--model`.
|
|
22
22
|
*/
|
|
23
23
|
model?: string;
|
|
24
24
|
description?: string;
|
|
25
25
|
manifest: {
|
|
26
|
-
/**
|
|
26
|
+
/** Baseline tools (non-empty; must include todo_write + ask_user_question for coordination). */
|
|
27
27
|
tools: string[];
|
|
28
|
-
/**
|
|
28
|
+
/** Three-state permissions; `*` supplies the default; omitted means `{"*":"allow"}`. */
|
|
29
29
|
rules?: Record<string, PermissionAction>;
|
|
30
30
|
/**
|
|
31
|
-
* D82 unknownTools
|
|
32
|
-
* master/worker = allow
|
|
33
|
-
*
|
|
34
|
-
*
|
|
31
|
+
* D82 unknownTools: stance for tools outside the manifest (default deny is the safe default).
|
|
32
|
+
* master/worker = allow (user-installed extensions are visible and usable by default—install grants access);
|
|
33
|
+
* custom roles default to deny. `*` in rules and this stance cover separate axes: visibility vs enforcement.
|
|
34
|
+
* Excluded families (such as worker's subagent/terminal) need explicit deny to remain blocked under allow.
|
|
35
35
|
*/
|
|
36
36
|
unknownTools?: UnknownToolStance;
|
|
37
37
|
};
|
|
38
38
|
services?: {
|
|
39
39
|
todos?: {
|
|
40
40
|
mode?: TodosMode;
|
|
41
|
-
reminderLimit?: number;
|
|
42
41
|
};
|
|
43
42
|
};
|
|
44
43
|
}
|
|
@@ -47,7 +46,7 @@ export type ValidateResult =
|
|
|
47
46
|
| { ok: true; value: RoleManifest }
|
|
48
47
|
| { ok: false; code: 'INVALID_ROLE_CONFIG'; issues: string[] };
|
|
49
48
|
|
|
50
|
-
/**
|
|
49
|
+
/** Coordination tools required by every role (C7 boundary constraint 1). */
|
|
51
50
|
export const COORDINATION_TOOLS = ['todo_write', 'ask_user_question'] as const;
|
|
52
51
|
|
|
53
52
|
const ROLE_NAME_RE = /^[a-z0-9-]+$/;
|
|
@@ -69,30 +68,30 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
69
68
|
return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role 档案必须是 JSON 对象'] };
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
/* ──
|
|
71
|
+
/* ── Top-level keys ── */
|
|
73
72
|
const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'manifest', 'services']);
|
|
74
73
|
for (const k of Object.keys(input)) {
|
|
75
74
|
if (!KNOWN_TOP.has(k)) issues.push(`未知顶层键 "${k}"(契约外字段,检查拼写)`);
|
|
76
75
|
}
|
|
77
76
|
|
|
78
|
-
/* ── role
|
|
77
|
+
/* ── role name ── */
|
|
79
78
|
if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
|
|
80
79
|
issues.push(`role 必须是 [a-z0-9-]+ 字符串,收到 ${JSON.stringify(input.role)}`);
|
|
81
80
|
}
|
|
82
81
|
|
|
83
|
-
/* ── version
|
|
82
|
+
/* ── version (P2 semver) ── */
|
|
84
83
|
if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
|
|
85
84
|
issues.push(`version 必须是 x.y.z 三段数字(如 1.0.0),收到 ${JSON.stringify(input.version)}`);
|
|
86
85
|
}
|
|
87
86
|
|
|
88
|
-
/* ── model
|
|
87
|
+
/* ── model (WS-D10: provider/model role routing; optional) ── */
|
|
89
88
|
if (input.model !== undefined) {
|
|
90
89
|
if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
|
|
91
90
|
issues.push(`model 必须是 provider/model 形态(如 opencode-go/muse-spark-1.2-contributor),收到 ${JSON.stringify(input.model)}`);
|
|
92
91
|
}
|
|
93
92
|
}
|
|
94
93
|
|
|
95
|
-
/* ── description
|
|
94
|
+
/* ── optional description ── */
|
|
96
95
|
if (input.description !== undefined && typeof input.description !== 'string') {
|
|
97
96
|
issues.push('description 必须是字符串');
|
|
98
97
|
}
|
|
@@ -107,12 +106,12 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
107
106
|
if (!KNOWN_MANIFEST.has(k)) issues.push(`manifest 内未知键 "${k}"`);
|
|
108
107
|
}
|
|
109
108
|
|
|
110
|
-
// D82 unknownTools
|
|
109
|
+
// D82 unknownTools: enum validation; omitted means deny (safe default).
|
|
111
110
|
if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
|
|
112
111
|
issues.push(`manifest.unknownTools 必须是 allow/deny,收到 ${JSON.stringify(m.unknownTools)}`);
|
|
113
112
|
}
|
|
114
113
|
|
|
115
|
-
// tools
|
|
114
|
+
// tools must be a unique, non-empty string array containing coordination tools; an empty array also reports the omissions.
|
|
116
115
|
const tools = m.tools;
|
|
117
116
|
if (!Array.isArray(tools) || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
|
|
118
117
|
issues.push('manifest.tools 必须是非空字符串数组(工具名 [a-z0-9_-])');
|
|
@@ -127,7 +126,7 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
127
126
|
}
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
// rules
|
|
129
|
+
// rules accept three-state values; each key must be a tool name or *.
|
|
131
130
|
if (m.rules !== undefined) {
|
|
132
131
|
if (!isPlainObject(m.rules)) {
|
|
133
132
|
issues.push('manifest.rules 必须是对象');
|
|
@@ -154,9 +153,6 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
154
153
|
if (t.mode !== undefined && t.mode !== 'serial' && t.mode !== 'parallel') {
|
|
155
154
|
issues.push(`services.todos.mode 必须是 serial/parallel,收到 ${JSON.stringify(t.mode)}`);
|
|
156
155
|
}
|
|
157
|
-
if (t.reminderLimit !== undefined && !isPosInt(t.reminderLimit)) {
|
|
158
|
-
issues.push(`services.todos.reminderLimit 必须是正整数,收到 ${JSON.stringify(t.reminderLimit)}`);
|
|
159
|
-
}
|
|
160
156
|
}
|
|
161
157
|
}
|
|
162
158
|
}
|
package/src/roles/master.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "master",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "主控角色(WS-D7 自应用;D83 镜像源):自研核心全量(todos/协调/subagent 族/终端族)+ pi 内建检视执行 + web_search;D82 unknownTools=allow——用户安装的任意扩展默认可见可用(install 即授予)",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"services": {
|
|
19
|
-
"todos": { "mode": "parallel"
|
|
19
|
+
"todos": { "mode": "parallel" }
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "worker-default",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent 族(禁嵌套)减 terminal 族(M14 资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent 族(禁嵌套)减 terminal 族(M14 资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
7
7
|
"tools": [
|
|
@@ -25,6 +25,6 @@
|
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
"services": {
|
|
28
|
-
"todos": { "mode": "serial"
|
|
28
|
+
"todos": { "mode": "serial" }
|
|
29
29
|
}
|
|
30
30
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RuntimePolicy {
|
|
9
|
+
/** Subagent overall timeout (ms) */
|
|
10
|
+
readonly subagentTimeoutMs: number
|
|
11
|
+
/** Subagent GC tick interval (ms) */
|
|
12
|
+
readonly gcTickMs: number
|
|
13
|
+
/** Poll interval for subagent state observation (ms) */
|
|
14
|
+
readonly pollIntervalMs: number
|
|
15
|
+
/** Stale check cadence (turns) */
|
|
16
|
+
readonly staleCadenceTurns: number
|
|
17
|
+
/** Settlement notice window / machine-inject grace / takeover idle (ms) */
|
|
18
|
+
readonly settlementWindowMs: number
|
|
19
|
+
/** Post-settle observation window before auto-consume (ms) */
|
|
20
|
+
readonly observationWindowMs: number
|
|
21
|
+
/** Foreground patience before background promotion (ms) */
|
|
22
|
+
readonly foregroundPatienceMs: number
|
|
23
|
+
/** Default session TTL (seconds) */
|
|
24
|
+
readonly sessionTtlSeconds: number
|
|
25
|
+
/** Terminal read maximum bytes */
|
|
26
|
+
readonly terminalReadMaxBytes: number
|
|
27
|
+
/** Git operation timeout (ms) */
|
|
28
|
+
readonly gitTimeoutMs: number
|
|
29
|
+
/** Subagent pane pipe readiness wait (ms) */
|
|
30
|
+
readonly readinessTimeoutMs: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseEnvInt(key: string, defaultValue: number, min: number = 0): number {
|
|
34
|
+
const raw = process.env[key]
|
|
35
|
+
if (!raw) return defaultValue
|
|
36
|
+
|
|
37
|
+
const parsed = Number.parseInt(raw, 10)
|
|
38
|
+
if (!Number.isFinite(parsed) || parsed < min) {
|
|
39
|
+
console.warn(`Invalid ${key}="${raw}", using default ${defaultValue}`)
|
|
40
|
+
return defaultValue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return parsed
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createRuntimePolicy(overrides?: Partial<RuntimePolicy>): RuntimePolicy {
|
|
47
|
+
return {
|
|
48
|
+
subagentTimeoutMs: overrides?.subagentTimeoutMs ?? parseEnvInt('PIER_SUBAGENT_TIMEOUT_MS', 600_000, 1000),
|
|
49
|
+
gcTickMs: overrides?.gcTickMs ?? parseEnvInt('PIER_GC_TICK_MS', 30_000, 1000),
|
|
50
|
+
pollIntervalMs: overrides?.pollIntervalMs ?? parseEnvInt('PIER_POLL_INTERVAL_MS', 30_000, 1000),
|
|
51
|
+
staleCadenceTurns: overrides?.staleCadenceTurns ?? parseEnvInt('PIER_STALE_CADENCE_TURNS', 3, 1),
|
|
52
|
+
settlementWindowMs: overrides?.settlementWindowMs ?? parseEnvInt('PIER_SETTLEMENT_WINDOW_MS', 60_000, 0),
|
|
53
|
+
observationWindowMs: overrides?.observationWindowMs ?? parseEnvInt('PIER_OBSERVATION_WINDOW_MS', 30_000, 0),
|
|
54
|
+
foregroundPatienceMs: overrides?.foregroundPatienceMs ?? parseEnvInt('PIER_FOREGROUND_PATIENCE_MS', 300_000, 0),
|
|
55
|
+
sessionTtlSeconds: overrides?.sessionTtlSeconds ?? parseEnvInt('PIER_SESSION_TTL_SECONDS', 600, 0),
|
|
56
|
+
terminalReadMaxBytes: overrides?.terminalReadMaxBytes ?? parseEnvInt('TERM_READ_MAX', 4096, 1),
|
|
57
|
+
gitTimeoutMs: overrides?.gitTimeoutMs ?? parseEnvInt('PIER_GIT_TIMEOUT_MS', 10_000, 1),
|
|
58
|
+
readinessTimeoutMs: overrides?.readinessTimeoutMs ?? parseEnvInt('PIER_READY_TIMEOUT_MS', 30_000, 1000),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Singleton runtime policy for production use.
|
|
64
|
+
* Tests should inject via createRuntimePolicy().
|
|
65
|
+
*/
|
|
66
|
+
export const runtimePolicy = createRuntimePolicy()
|
package/src/session-tail.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi
|
|
2
|
+
* Tail parsing for pi session JSONL (pure library, v1.1 result channel).
|
|
3
3
|
*
|
|
4
|
-
* v1.1
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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):
|
|
8
|
+
* line = {type, id, parentId, message:{role, content:[{type:'text',text}...], timestamp, stopReason}}
|
|
8
9
|
*/
|
|
9
10
|
import * as fs from 'node:fs';
|
|
10
11
|
import * as path from 'node:path';
|
|
12
|
+
import { sessionDirCandidates, sessionDirName } from './storage-layout.ts';
|
|
13
|
+
|
|
14
|
+
export { sessionDirName };
|
|
11
15
|
|
|
12
16
|
export interface SessionMessageLike {
|
|
13
17
|
role?: string;
|
|
@@ -21,8 +25,7 @@ export interface SessionEntryLike {
|
|
|
21
25
|
message?: SessionMessageLike | unknown;
|
|
22
26
|
[k: string]: unknown;
|
|
23
27
|
}
|
|
24
|
-
|
|
25
|
-
/** 容忍式逐行解析(损坏行/非 JSON 行跳过)。 */
|
|
28
|
+
/** Parse line by line tolerantly, skipping malformed or non-JSON records. */
|
|
26
29
|
export function parseSessionEntries(text: string): SessionEntryLike[] {
|
|
27
30
|
const entries: SessionEntryLike[] = [];
|
|
28
31
|
for (const line of (text ?? '').split(/\r?\n/)) {
|
|
@@ -32,7 +35,7 @@ export function parseSessionEntries(text: string): SessionEntryLike[] {
|
|
|
32
35
|
const obj = JSON.parse(t);
|
|
33
36
|
if (obj && typeof obj === 'object') entries.push(obj as SessionEntryLike);
|
|
34
37
|
} catch {
|
|
35
|
-
/*
|
|
38
|
+
/* Skip truncated or malformed records. */
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
return entries;
|
|
@@ -58,10 +61,9 @@ function textOf(m: SessionMessageLike): string {
|
|
|
58
61
|
.join('\n')
|
|
59
62
|
.trim();
|
|
60
63
|
}
|
|
61
|
-
|
|
62
64
|
/**
|
|
63
|
-
*
|
|
64
|
-
* sinceTs
|
|
65
|
+
* Return the latest finalized assistant text (stopReason === 'stop'), excluding toolUse intermediates.
|
|
66
|
+
* sinceTs filters out messages that predate the injection point.
|
|
65
67
|
*/
|
|
66
68
|
export function lastAssistantText(
|
|
67
69
|
entries: readonly SessionEntryLike[],
|
|
@@ -78,8 +80,7 @@ export function lastAssistantText(
|
|
|
78
80
|
}
|
|
79
81
|
return best;
|
|
80
82
|
}
|
|
81
|
-
|
|
82
|
-
/** 会话是否出现注入时间点之后的 assistant 消息(任意 stopReason,探测活跃)。 */
|
|
83
|
+
/** Return whether any assistant message occurs after the injection point, regardless of stopReason. */
|
|
83
84
|
export function hasAssistantAfter(
|
|
84
85
|
entries: readonly SessionEntryLike[],
|
|
85
86
|
sinceTs: number,
|
|
@@ -89,10 +90,10 @@ export function hasAssistantAfter(
|
|
|
89
90
|
return !!m && m.role === 'assistant' && (typeof m.timestamp === 'number' ? m.timestamp : 0) >= sinceTs;
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
|
-
|
|
93
93
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
94
|
+
* Return whether an initiated toolCall still lacks a result after injection (for example,
|
|
95
|
+
* ask_user_question waiting on human input), meaning settlement has not actually completed
|
|
96
|
+
* (v1.3 M8 settlement-race fix). Parallel calls are tracked by depth.
|
|
96
97
|
*/
|
|
97
98
|
export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs: number): boolean {
|
|
98
99
|
let depth = 0;
|
|
@@ -112,29 +113,27 @@ export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs
|
|
|
112
113
|
return depth > 0;
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
/** pi 会话目录名:cwd → `--F--herdr-pi--` 形态(实测命名约定)。 */
|
|
116
|
-
export function sessionDirName(cwd: string): string {
|
|
117
|
-
const flat = cwd.replace(/[\\/]/g, '-').replace(/:/g, '-');
|
|
118
|
-
return `--${flat}--`;
|
|
119
|
-
}
|
|
120
116
|
|
|
121
|
-
|
|
117
|
+
|
|
118
|
+
/** Newest `limit` session files under cwd's session dir (new encoding, then legacy). */
|
|
122
119
|
export function listSessionFiles(cwd: string, agentDir: string, limit = 4): string[] {
|
|
123
|
-
const dir = path.join(agentDir, sessionDirName(cwd));
|
|
124
|
-
let names: string[];
|
|
125
|
-
try {
|
|
126
|
-
names = fs.readdirSync(dir);
|
|
127
|
-
} catch {
|
|
128
|
-
return [];
|
|
129
|
-
}
|
|
130
120
|
const files: Array<{ file: string; mtime: number }> = [];
|
|
131
|
-
for (const name of
|
|
132
|
-
|
|
133
|
-
|
|
121
|
+
for (const name of sessionDirCandidates(cwd)) {
|
|
122
|
+
const dir = path.join(agentDir, name);
|
|
123
|
+
let names: string[];
|
|
134
124
|
try {
|
|
135
|
-
|
|
125
|
+
names = fs.readdirSync(dir);
|
|
136
126
|
} catch {
|
|
137
|
-
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const fileName of names) {
|
|
130
|
+
if (!fileName.endsWith('.jsonl')) continue;
|
|
131
|
+
const full = path.join(dir, fileName);
|
|
132
|
+
try {
|
|
133
|
+
files.push({ file: full, mtime: fs.statSync(full).mtimeMs });
|
|
134
|
+
} catch {
|
|
135
|
+
/* vanished between readdir and stat */
|
|
136
|
+
}
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
files.sort((a, b) => b.mtime - a.mtime);
|
|
@@ -142,20 +141,22 @@ export function listSessionFiles(cwd: string, agentDir: string, limit = 4): stri
|
|
|
142
141
|
}
|
|
143
142
|
|
|
144
143
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
144
|
+
* Locate a session file by id (`<ts>_<id>.jsonl`).
|
|
145
|
+
* Searches the new encoding dir first, then the legacy flattened dir.
|
|
147
146
|
*/
|
|
148
147
|
export function sessionFileById(cwd: string, agentDir: string, id: string): string | null {
|
|
149
|
-
const dir = path.join(agentDir, sessionDirName(cwd));
|
|
150
148
|
const suffix = `_${id}.jsonl`;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
names
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
149
|
+
for (const name of sessionDirCandidates(cwd)) {
|
|
150
|
+
const dir = path.join(agentDir, name);
|
|
151
|
+
let names: string[];
|
|
152
|
+
try {
|
|
153
|
+
names = fs.readdirSync(dir);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
for (const fileName of names) {
|
|
158
|
+
if (fileName.endsWith(suffix)) return path.join(dir, fileName);
|
|
159
|
+
}
|
|
159
160
|
}
|
|
160
161
|
return null;
|
|
161
162
|
}
|
package/src/settle-wake-core.ts
CHANGED
|
@@ -1,40 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* settled
|
|
2
|
+
* Pure core for the settled-wake decision (prevents wake storms; evidenced by session 01a03bf0).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* sendUserMessage(followUp)
|
|
6
|
-
* 30
|
|
7
|
-
*
|
|
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
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
* - D96
|
|
13
|
-
* - D96
|
|
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).
|
|
14
14
|
*/
|
|
15
|
+
|
|
15
16
|
export const ABORT_STOP_REASON = 'aborted';
|
|
16
17
|
|
|
17
|
-
/** D96
|
|
18
|
+
/** D96: cooldown before repeating a notice for the same running set. */
|
|
18
19
|
export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
|
|
19
20
|
|
|
20
21
|
export interface SettleWakeInput {
|
|
21
|
-
/**
|
|
22
|
+
/** stopReason of the last assistant turn before this settlement (unknown = null, treated as natural completion). */
|
|
22
23
|
lastStopReason: string | null;
|
|
23
|
-
/**
|
|
24
|
+
/** Background subagents still running (empty means none). */
|
|
24
25
|
running: ReadonlyArray<{ paneId: string }>;
|
|
25
|
-
/**
|
|
26
|
+
/** Set key recorded for the previous D96 notice (none = null). */
|
|
26
27
|
lastNoticeKey: string | null;
|
|
27
|
-
/**
|
|
28
|
+
/** Timestamp of the previous D96 notice (epoch ms). */
|
|
28
29
|
lastNoticeAt: number;
|
|
29
30
|
now: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export interface SettleWakePlan {
|
|
33
|
-
/** false
|
|
34
|
+
/** false means this settlement stays silent and emits no wake-up message. */
|
|
34
35
|
wake: boolean;
|
|
35
|
-
/**
|
|
36
|
+
/** Whether to inject the D96 "still running" notice. */
|
|
36
37
|
notice: boolean;
|
|
37
|
-
/**
|
|
38
|
+
/** New set key (updated when notifying; empty running set → null). */
|
|
38
39
|
noticeKey: string | null;
|
|
39
40
|
noticeAt: number;
|
|
40
41
|
}
|
package/src/slim-frame.ts
CHANGED
|
@@ -1,47 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* D97
|
|
3
|
-
*
|
|
2
|
+
* D97 slim frame: when a pane is too narrow or short for the minimum usable TUI,
|
|
3
|
+
* cover it with a non-capturing full-screen overlay containing a static frame sourced from the pane title.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* formatPaneTitle
|
|
8
|
-
*
|
|
5
|
+
* Why (observed by the user): after the heat view compresses an unfocused pane, streaming worker thinking
|
|
6
|
+
* continuously repaints the entire screen in the narrow pane, causing flicker. The static frame updates only on
|
|
7
|
+
* todo_write/status reports (sharing arguments and source with formatPaneTitle), so thinking tokens cannot reach it;
|
|
8
|
+
* alt-screen row-level diffing then produces zero PTY output and physically eliminates the flicker.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* 文档之内。
|
|
10
|
+
* Prerequisite: worker/master run with --tui-mode fullscreen (injected by default by buildLaunchParts).
|
|
11
|
+
* The regular main-screen full dump path (tui-main-screen firstChanged < viewportTop → full redraw) cannot be
|
|
12
|
+
* covered by the overlay: composite is above the document, while the dump is inside the document.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Registration gate: only inside a herdr pane (index.ts checks the environment), because covering an interactive
|
|
15
|
+
* normal terminal window would be hazardous. Clicking a narrow herdr pane focuses it and the heat view expands it;
|
|
16
|
+
* the visible predicate re-evaluates on every frame as SIGWINCH arrives, so the frame disappears automatically and
|
|
17
|
+
* the real TUI remains underneath (agent/session/widget execution never stops).
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* PI_HERDR_SLIM_FRAME=0
|
|
19
|
+
* Lifecycle: one process-local singleton, registered once on session_start (not resume); the overlay stays resident
|
|
20
|
+
* (done() is never called, leaving the Promise pending, swallowing rejection, and never awaiting it). If switching
|
|
21
|
+
* pi sessions removes the overlay (resetExtensionUI), the static frame naturally exits without affecting the main
|
|
22
|
+
* flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
/** TUI
|
|
26
|
-
*
|
|
25
|
+
/** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
|
|
26
|
+
* Below either threshold the pane cannot display a readable TUI, so it renders the title as a static frame. */
|
|
27
|
+
|
|
27
28
|
export const SLIM_MIN_COLS = 24;
|
|
28
29
|
export const SLIM_MIN_ROWS = 12;
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
+
/** Visibility predicate (overlayOptions.visible invokes it on every render frame). */
|
|
31
32
|
export function isSlimFrame(cols: number, rows: number): boolean {
|
|
32
33
|
return cols < SLIM_MIN_COLS || rows < SLIM_MIN_ROWS;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
/* ──
|
|
36
|
+
/* ── Width-aware wrapping (conservative East Asian width; ambiguous glyphs such as ▶○■✓ count as 1, affecting only break positions) ── */
|
|
36
37
|
|
|
37
38
|
function isWide(cp: number): boolean {
|
|
38
39
|
return (
|
|
39
|
-
(cp >= 0x1100 && cp <= 0x115f) //
|
|
40
|
-
|| (cp >= 0x2e80 && cp <= 0xa4cf) // CJK
|
|
41
|
-
|| (cp >= 0xac00 && cp <= 0xd7a3) //
|
|
42
|
-
|| (cp >= 0xf900 && cp <= 0xfaff) // CJK
|
|
43
|
-
|| (cp >= 0xfe30 && cp <= 0xfe4f) // CJK
|
|
44
|
-
|| (cp >= 0xff00 && cp <= 0xff60) //
|
|
40
|
+
(cp >= 0x1100 && cp <= 0x115f) // Hangul compatibility jamo
|
|
41
|
+
|| (cp >= 0x2e80 && cp <= 0xa4cf) // CJK radicals through Yi (including kana)
|
|
42
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3) // Hangul syllables
|
|
43
|
+
|| (cp >= 0xf900 && cp <= 0xfaff) // CJK compatibility ideographs
|
|
44
|
+
|| (cp >= 0xfe30 && cp <= 0xfe4f) // CJK compatibility forms
|
|
45
|
+
|| (cp >= 0xff00 && cp <= 0xff60) // Fullwidth forms
|
|
45
46
|
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
46
47
|
);
|
|
47
48
|
}
|
|
@@ -53,8 +54,8 @@ export function displayWidth(s: string): number {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
|
-
*
|
|
57
|
-
* CJK
|
|
57
|
+
* Wrap at visible width: prefer word breaks (retreat to the last space instead of cutting an English phrase);
|
|
58
|
+
* CJK text without spaces hard-wraps naturally. Consume the space at the break point.
|
|
58
59
|
*/
|
|
59
60
|
export function wrapByWidth(text: string, width: number): string[] {
|
|
60
61
|
const w = Math.max(1, Math.floor(width));
|
|
@@ -89,9 +90,9 @@ export function wrapByWidth(text: string, width: number): string[] {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
|
-
*
|
|
93
|
-
* overlay
|
|
94
|
-
*
|
|
93
|
+
* Frame lines: wrap the title, center it vertically, and clamp to rows. Pad every line to full visible width:
|
|
94
|
+
* row-wise overlay compositing would let the TUI underneath show through blank lines (leaking flicker),
|
|
95
|
+
* while full-width spaces make the freeze genuinely opaque. Without a title, center one `·` (alive, no plan).
|
|
95
96
|
*/
|
|
96
97
|
export function frameLines(
|
|
97
98
|
text: string,
|
|
@@ -114,7 +115,7 @@ export function frameLines(
|
|
|
114
115
|
return out;
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
/* ──
|
|
118
|
+
/* ── Overlay component plus registration/update (process-local singleton) ─────────────────────────── */
|
|
118
119
|
|
|
119
120
|
interface FrameTui {
|
|
120
121
|
requestRender(): void;
|
|
@@ -132,11 +133,11 @@ class SlimFrameComponent {
|
|
|
132
133
|
this.colorize = colorize;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
/** visible
|
|
136
|
+
/** The visible predicate runs each frame; record row count because render(width) lacks height for centered layout. */
|
|
136
137
|
onViewport(cols: number, rows: number): boolean {
|
|
137
138
|
if (this.lastRows !== rows) {
|
|
138
139
|
this.lastRows = rows;
|
|
139
|
-
this.cache = null; //
|
|
140
|
+
this.cache = null; // A row-count change requires re-centering.
|
|
140
141
|
}
|
|
141
142
|
return isSlimFrame(cols, rows);
|
|
142
143
|
}
|
|
@@ -159,14 +160,14 @@ class SlimFrameComponent {
|
|
|
159
160
|
this.cache = null;
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
/** pi
|
|
163
|
+
/** Reclaim the module singleton when pi tears down the overlay (session switch/exit), allowing re-registration. */
|
|
163
164
|
dispose(): void {
|
|
164
165
|
if (active === this) active = null;
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
|
|
169
|
-
/**
|
|
170
|
+
/** Test injection point (reset module state). */
|
|
170
171
|
export function resetForTest(): void {
|
|
171
172
|
active = null;
|
|
172
173
|
}
|
|
@@ -174,8 +175,8 @@ export function resetForTest(): void {
|
|
|
174
175
|
let active: SlimFrameComponent | null = null;
|
|
175
176
|
|
|
176
177
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
178
|
+
* Register the static-frame overlay (idempotent: once per process). eventCtx is the pi lifecycle event context
|
|
179
|
+
* (the session_start ctx, sourced from the same place as the todo widget's widgetUi).
|
|
179
180
|
*/
|
|
180
181
|
export function registerSlimFrame(eventCtx: unknown): void {
|
|
181
182
|
if (active) return;
|
|
@@ -187,8 +188,8 @@ export function registerSlimFrame(eventCtx: unknown): void {
|
|
|
187
188
|
? ui.custom
|
|
188
189
|
: undefined;
|
|
189
190
|
try {
|
|
190
|
-
// overlay: true
|
|
191
|
-
// nonCapturing
|
|
191
|
+
// overlay: true keeps the floating layer from clearing the screen (pi ctx.ui.custom replaces the editor area by default, so cover it explicitly);
|
|
192
|
+
// nonCapturing leaves the keyboard with the editor (typing in a focused narrow pane remains possible, and the expanded heat view hides the frame).
|
|
192
193
|
const pending = custom(
|
|
193
194
|
(tui, theme) => {
|
|
194
195
|
const comp = new SlimFrameComponent(tui, (s) => theme.fg('accent', s));
|
|
@@ -206,14 +207,14 @@ export function registerSlimFrame(eventCtx: unknown): void {
|
|
|
206
207
|
},
|
|
207
208
|
},
|
|
208
209
|
);
|
|
209
|
-
// done()
|
|
210
|
+
// Never call done(), leaving the Promise pending so the overlay stays resident; swallow rejection and never await it.
|
|
210
211
|
Promise.resolve(pending).catch(() => {});
|
|
211
212
|
} catch {
|
|
212
|
-
/*
|
|
213
|
+
/* Silently degrade on pi versions without overlay support. */
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
/**
|
|
217
|
+
/** Update static-frame content (same arguments/source as the pane-title projection; null means no plan). */
|
|
217
218
|
export function updateSlimFrame(title: string | null): void {
|
|
218
219
|
active?.setText(title ?? '');
|
|
219
220
|
}
|