pi-pier 0.1.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/README.md +62 -0
- package/package.json +55 -0
- package/schemas/role-manifest.schema.json +53 -0
- package/skills/pi-herdr-workflow/SKILL.md +86 -0
- package/src/bootstrap.ts +106 -0
- package/src/core/grid-shape.ts +86 -0
- package/src/core/subagent.ts +1256 -0
- package/src/core/terminal.ts +359 -0
- package/src/core/todo.ts +313 -0
- package/src/gc-core.ts +56 -0
- package/src/herdr-client.ts +579 -0
- package/src/history-store.ts +115 -0
- package/src/index.ts +751 -0
- package/src/ledger.ts +71 -0
- package/src/lock-core.ts +155 -0
- package/src/manifest-compose.ts +102 -0
- package/src/notice-buffer.ts +28 -0
- package/src/pane-title.ts +84 -0
- package/src/pi-surface.ts +135 -0
- package/src/pipe-channel.ts +181 -0
- package/src/progress-core.ts +109 -0
- package/src/reconcile-core.ts +130 -0
- package/src/role-loader.ts +164 -0
- package/src/role-manifest.ts +166 -0
- package/src/roles/master.json +21 -0
- package/src/roles/worker-default.json +30 -0
- package/src/session-tail.ts +169 -0
- package/src/subagent-core.ts +310 -0
- package/src/subagent-scope.ts +39 -0
- package/src/terminal-core.ts +328 -0
- package/src/todo-core.ts +331 -0
- package/src/todo-read-hook.ts +54 -0
- package/src/todos-service.ts +66 -0
- package/src/tool-gate.ts +106 -0
- package/src/vocab.ts +82 -0
package/src/gc-core.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务 tab GC 决策(D29 的判定规则抽成纯函数;M22 取消 resident 豁免)。
|
|
3
|
+
* 无副作用、无 pi/herdr API 依赖 → 可单测。
|
|
4
|
+
*/
|
|
5
|
+
export type GcEntryKind = string;
|
|
6
|
+
export type GcEntryStatus = 'running' | 'settled' | 'consumed' | 'closed';
|
|
7
|
+
|
|
8
|
+
export interface GcEntryLike {
|
|
9
|
+
/** 'task' 或 role 名;旧值 short/resident 只作标签,不参与判定。 */
|
|
10
|
+
kind: GcEntryKind;
|
|
11
|
+
status: GcEntryStatus;
|
|
12
|
+
/** 消费时间(GC 宽限期判据;closed 条目保留原值)。 */
|
|
13
|
+
consumedAt?: number | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isFinished(status: GcEntryStatus): boolean {
|
|
17
|
+
return status === 'consumed' || status === 'closed' || status === 'settled';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* D29 / M22:任务 tab 是否该关(全部条件同时成立):
|
|
22
|
+
* - 含 ≥1 个工作 pane(主 tab 无委派条目 → 永不关);
|
|
23
|
+
* - 全部工作 pane 已 settled/consumed/closed(kind 不再豁免);
|
|
24
|
+
* - 宽限期已过(时间制 TTL;ttlMs=0 表示不自动关,由调用方把关);
|
|
25
|
+
* - 无 blocked pane(人类闸门豁免);
|
|
26
|
+
* - 其余 pane 全部 idle/done/unknown(非工作 pane 允许 unknown)。
|
|
27
|
+
*/
|
|
28
|
+
export function shouldCloseTaskTab(opts: {
|
|
29
|
+
entries: readonly GcEntryLike[];
|
|
30
|
+
paneStatuses: readonly string[];
|
|
31
|
+
ttlMs: number;
|
|
32
|
+
now: number;
|
|
33
|
+
}): boolean {
|
|
34
|
+
if (opts.entries.length === 0) return false;
|
|
35
|
+
if (!opts.entries.every((e) => isFinished(e.status))) return false;
|
|
36
|
+
if (!opts.entries.every((e) => (e.consumedAt ?? 0) > 0 && (e.consumedAt ?? 0) < opts.now - opts.ttlMs)) return false;
|
|
37
|
+
if (opts.paneStatuses.some((s) => s === 'blocked')) return false;
|
|
38
|
+
if (!opts.paneStatuses.every((s) => s === 'idle' || s === 'done' || s === 'unknown')) return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* pane 级回收判定(孤儿/兼容路径,v1.2 语义):
|
|
44
|
+
* - 消费于上一轮之前(宽限:结算通知那一轮人可看、模型可用);
|
|
45
|
+
* - herdr 显式状态 idle/done(unknown/working/blocked → 下轮重试,#943 容错);
|
|
46
|
+
* - pane 已消失(status undefined)→ 补记 closed(返回 true 由调用方处理)。
|
|
47
|
+
*/
|
|
48
|
+
export function shouldClosePane(opts: {
|
|
49
|
+
consumedAt: number | null;
|
|
50
|
+
herdrStatus: string | undefined;
|
|
51
|
+
prevTurnStart: number;
|
|
52
|
+
}): boolean {
|
|
53
|
+
if (opts.herdrStatus === undefined) return true; // pane 没了 → 补记 closed
|
|
54
|
+
if (opts.herdrStatus !== 'idle' && opts.herdrStatus !== 'done') return false;
|
|
55
|
+
return (opts.consumedAt ?? 0) > 0 && opts.consumedAt! < opts.prevTurnStart;
|
|
56
|
+
}
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr socket API 最小客户端(pi 扩展内使用)。
|
|
3
|
+
*
|
|
4
|
+
* 已对 herdr 0.8.0-preview(protocol 19, Windows named pipe)逐项实测(见 WIRE.md):
|
|
5
|
+
* - 传输:NDJSON;请求 {id, method, params} → 响应 {id, result} | {id, error:{code,message}};
|
|
6
|
+
* - **控制请求 = 一连接一请求**(server 应答后即关连接);
|
|
7
|
+
* - Windows 目标 = named pipe,名称为完整 socket_path 字符串,连接时前缀 \\.\pipe\;
|
|
8
|
+
* - pane.report_agent {pane_id, source, agent, state, message?, agent_session_id?, agent_session_path?}
|
|
9
|
+
* 其中 state ∈ idle|working|blocked|unknown('done' 是 server 派生状态,客户端不报);
|
|
10
|
+
* - pane.report_metadata {pane_id, source, title?, state_labels?, clear_title?, clear_state_labels?}
|
|
11
|
+
* M22 起只报标题投影;升级首次附带把旧 pi-herdr 分块 token 写成 null;
|
|
12
|
+
* - agent.list {} → {type:'agent_list', agents: AgentInfo[]};
|
|
13
|
+
* - agent.wait {target, until[], timeout_ms?} → 状态命中返回 agent,超时为错误(timeout 语义);
|
|
14
|
+
* - agent.send_keys {target, keys[]} / pane.send_text {pane_id, text}。
|
|
15
|
+
*
|
|
16
|
+
* v1.1(DESIGN.md §12)接口面:
|
|
17
|
+
* 报告 ×3、目录、spawn、注入(send_text)、按键(send_keys)、等待(agent.wait)、会话路径查询。
|
|
18
|
+
* 已删除:waitSettled(标记版)、readPaneText(pane 文本解析)、onAgentState(订阅长连接)
|
|
19
|
+
* —— 结果通道改会话 JSONL(session-tail.ts),状态等待改 server 端 agent.wait。
|
|
20
|
+
*
|
|
21
|
+
* 设计(DESIGN.md §4.1):无 herdr 环境 → Noop 降级,pi 独立可用;
|
|
22
|
+
* 上报类调用失败静默,绝不影响 pi 主流程。
|
|
23
|
+
*/
|
|
24
|
+
import { REPORT_AGENT_SOURCE, type TodoItem } from './vocab.ts';
|
|
25
|
+
import { BLOCKED_LABEL_KEY, SIDEBAR_ASK_TOKEN, formatBlockedLabel, formatPaneTitle, sidebarTodoTokens, staleTokenClearance } from './pane-title.ts';
|
|
26
|
+
import { LOCK_BATCH_LIMIT, LOCK_TTL_MS } from './lock-core.ts';
|
|
27
|
+
import * as net from 'node:net';
|
|
28
|
+
|
|
29
|
+
/* ── 类型 ──────────────────────────────────────────────────────────── */
|
|
30
|
+
|
|
31
|
+
/** herdr agent 语义状态(schema 实测五态;'done' 为 server 派生)。 */
|
|
32
|
+
export type HerdrAgentState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown';
|
|
33
|
+
|
|
34
|
+
export interface HerdrEnv {
|
|
35
|
+
socketPath: string;
|
|
36
|
+
paneId: string;
|
|
37
|
+
workspaceId: string;
|
|
38
|
+
tabId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** report_agent 可上报的状态('done' 由 server 派生)。 */
|
|
42
|
+
export type PaneAgentState = 'idle' | 'working' | 'blocked' | 'unknown';
|
|
43
|
+
|
|
44
|
+
export interface AgentInfo {
|
|
45
|
+
paneId: string;
|
|
46
|
+
agent: string | null;
|
|
47
|
+
status: HerdrAgentState;
|
|
48
|
+
/** agent_session.value(session id 或路径),供恢复/跳转。 */
|
|
49
|
+
session: string | null;
|
|
50
|
+
stateLabels: Record<string, string>;
|
|
51
|
+
tokens: Record<string, string | null>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** TabInfo 投影(schema 实测:tab_id/workspace_id/number/label/focused/pane_count/agent_status)。 */
|
|
55
|
+
export interface TabInfo {
|
|
56
|
+
tabId: string;
|
|
57
|
+
workspaceId: string;
|
|
58
|
+
label: string;
|
|
59
|
+
paneCount: number;
|
|
60
|
+
agentStatus: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function detectHerdrEnv(env: NodeJS.ProcessEnv = process.env): HerdrEnv | null {
|
|
64
|
+
if (env.HERDR_ENV !== '1') return null;
|
|
65
|
+
const socketPath = env.HERDR_SOCKET_PATH;
|
|
66
|
+
const paneId = env.HERDR_PANE_ID;
|
|
67
|
+
if (!socketPath || !paneId) return null;
|
|
68
|
+
return {
|
|
69
|
+
socketPath,
|
|
70
|
+
paneId,
|
|
71
|
+
workspaceId: env.HERDR_WORKSPACE_ID ?? '',
|
|
72
|
+
tabId: env.HERDR_TAB_ID ?? '',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HerdrClientLike {
|
|
77
|
+
readonly available: boolean;
|
|
78
|
+
reportAgent(state: PaneAgentState, message: string | null): Promise<void>;
|
|
79
|
+
reportAgentSession(sessionPath: string | null): Promise<void>;
|
|
80
|
+
reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null }): Promise<void>;
|
|
81
|
+
/** M18:写锁 token 上报(键 lock-<hash>,值 paneId|path;null = 释放;≤16 键/批自动分批)。 */
|
|
82
|
+
reportLockTokens(tokens: Record<string, string | null>): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* D93:侧边栏 agent 显示名(display_agent = role 名)。
|
|
85
|
+
* 一次上报持续生效(无 TTL);null = 清除回检测值。尽力而为。
|
|
86
|
+
*/
|
|
87
|
+
reportDisplayAgent(name: string | null): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* D95:ask_user_question 等待标志(tokens['pi-ask'];null 清空)。
|
|
90
|
+
* 侧边栏/热力分级用:blocked + pi-ask = 人类闸门(ask 级),纯 blocked = block 级。
|
|
91
|
+
*/
|
|
92
|
+
reportAskFlag(text: string | null): Promise<void>;
|
|
93
|
+
listAgents(): Promise<AgentInfo[]>;
|
|
94
|
+
/** 在新 tab 里以 argv 启动子 pane(layout.apply),返回 pane/tab id。 */
|
|
95
|
+
spawnSubPane(opts: { label: string; command: string[]; cwd: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
|
|
96
|
+
/** 向 pane 终端注入文本(pi 编辑器输入 + Enter;send_text+CR 实测可直达;仅用于 launchLine 起进程)。 */
|
|
97
|
+
sendPaneText(paneId: string, text: string): Promise<void>;
|
|
98
|
+
/** server 端等待 agent 状态;命中返回状态,超时返回 null,错误抛出。 */
|
|
99
|
+
waitAgent(paneId: string, until: HerdrAgentState[], timeoutMs: number): Promise<HerdrAgentState | null>;
|
|
100
|
+
/** 查询子代理上报的会话路径(agent_session.value,kind=path);无则 null。 */
|
|
101
|
+
getAgentSessionPath(paneId: string): Promise<string | null>;
|
|
102
|
+
/** v1.2:聚焦 pane(组 tab 追加的前置)。 */
|
|
103
|
+
focusPane(paneId: string): Promise<void>;
|
|
104
|
+
/** v1.2:在当前 tab 拆分出新 shell pane(focus 后调用,落组 tab)。返回新 paneId。 */
|
|
105
|
+
splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string }): Promise<string>;
|
|
106
|
+
/** v1.2:关闭 pane(实测杀进程树;空 tab 由 herdr 自动关)。 */
|
|
107
|
+
closePane(paneId: string): Promise<void>;
|
|
108
|
+
/** v1.2:创建 tab(含根 shell pane),返回 tabId/paneId(组 tab 基础设施)。 */
|
|
109
|
+
createTab(opts: { workspaceId: string; label?: string; cwd?: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }>;
|
|
110
|
+
/** v1.2:列出全部 pane(含 tab 归属;组 tab 追加用)。 */
|
|
111
|
+
listPanes(): Promise<Array<{ paneId: string; tabId: string; workspaceId: string; agentStatus: string }>>;
|
|
112
|
+
/** D91:导出 tab 布局树(layout.export;paneId/tabId 二选一定位)。尽力而为:失败返回 null。 */
|
|
113
|
+
exportLayout(opts: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown } | null>;
|
|
114
|
+
/** v1.3:列出 tab(tab.list → {type:'tab_list', tabs:[TabInfo]},schema 实测)。 */
|
|
115
|
+
tabList(): Promise<TabInfo[]>;
|
|
116
|
+
/** v1.3:查 tab 详情(tab.get → {type:'tab_info', tab});不存在返回 null。 */
|
|
117
|
+
tabGet(tabId: string): Promise<TabInfo | null>;
|
|
118
|
+
/** v1.3:关闭 tab(tab.close → {type:'tab_closed', ...};级联关闭其 pane)。 */
|
|
119
|
+
tabClose(tabId: string): Promise<void>;
|
|
120
|
+
/** M14:发送按键组合(pane.send_keys;Herdr key-combo:ctrl+c / enter / esc …)。 */
|
|
121
|
+
sendPaneKeys(paneId: string, keys: string[]): Promise<void>;
|
|
122
|
+
/** M14:读 pane 输出缓冲(pane.read;source=recent 近期缓冲,strip_ansi=false 保留 T6 检测原料)。 */
|
|
123
|
+
readPane(paneId: string, opts?: {
|
|
124
|
+
source?: 'visible' | 'recent' | 'recent_unwrapped';
|
|
125
|
+
lines?: number;
|
|
126
|
+
stripAnsi?: boolean;
|
|
127
|
+
}): Promise<{ text: string; revision: number; truncated: boolean }>;
|
|
128
|
+
/** M14:等输出匹配(pane.wait_for_output;substring/regex)。超时返回 null,错误抛出。 */
|
|
129
|
+
waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<boolean | null>;
|
|
130
|
+
close(): void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 深度优先在响应对象里找第一个字符串字段(herdr 信封形状多样,id 位置不一)。 */
|
|
134
|
+
function findIdIn(obj: unknown, key: string, depth = 0): string | null {
|
|
135
|
+
if (!obj || depth > 6) return null;
|
|
136
|
+
if (typeof obj === 'object') {
|
|
137
|
+
const v = (obj as Record<string, unknown>)[key];
|
|
138
|
+
if (typeof v === 'string') return v;
|
|
139
|
+
for (const child of Object.values(obj as Record<string, unknown>)) {
|
|
140
|
+
const r = findIdIn(child, key, depth + 1);
|
|
141
|
+
if (r) return r;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── Noop ──────────────────────────────────────────────────────────── */
|
|
148
|
+
|
|
149
|
+
export class NoopHerdrClient implements HerdrClientLike {
|
|
150
|
+
readonly available = false;
|
|
151
|
+
async reportAgent(): Promise<void> {}
|
|
152
|
+
async reportAgentSession(): Promise<void> {}
|
|
153
|
+
async reportMetadata(): Promise<void> {}
|
|
154
|
+
async reportLockTokens(): Promise<void> {}
|
|
155
|
+
async reportDisplayAgent(): Promise<void> {}
|
|
156
|
+
async reportAskFlag(): Promise<void> {}
|
|
157
|
+
async listAgents(): Promise<AgentInfo[]> {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
async spawnSubPane(): Promise<{ tabId: string; paneId: string }> {
|
|
161
|
+
throw new Error('subagent requires a herdr-managed pane');
|
|
162
|
+
}
|
|
163
|
+
async sendPaneText(): Promise<void> {}
|
|
164
|
+
async waitAgent(): Promise<null> {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
async getAgentSessionPath(): Promise<null> {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
async focusPane(): Promise<void> {}
|
|
171
|
+
async splitPane(): Promise<string> {
|
|
172
|
+
throw new Error('subagent requires a herdr-managed pane');
|
|
173
|
+
}
|
|
174
|
+
async closePane(): Promise<void> {}
|
|
175
|
+
async createTab(): Promise<{ tabId: string; paneId: string }> {
|
|
176
|
+
throw new Error('subagent requires a herdr-managed pane');
|
|
177
|
+
}
|
|
178
|
+
async listPanes(): Promise<Array<{ paneId: string; tabId: string; workspaceId: string; agentStatus: string }>> {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
async exportLayout(): Promise<null> {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
async tabList(): Promise<TabInfo[]> {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
async tabGet(): Promise<TabInfo | null> {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
async tabClose(): Promise<void> {}
|
|
191
|
+
async sendPaneKeys(): Promise<void> {}
|
|
192
|
+
async readPane(): Promise<{ text: string; revision: number; truncated: boolean }> {
|
|
193
|
+
return { text: '', revision: 0, truncated: false };
|
|
194
|
+
}
|
|
195
|
+
async waitForOutput(): Promise<boolean | null> {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
close(): void {}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ── 实现 ──────────────────────────────────────────────────────────── */
|
|
202
|
+
|
|
203
|
+
export class HerdrClient implements HerdrClientLike {
|
|
204
|
+
readonly available = true;
|
|
205
|
+
private clearedStaleTokens = false;
|
|
206
|
+
|
|
207
|
+
constructor(private readonly env: HerdrEnv) {}
|
|
208
|
+
|
|
209
|
+
private target(): string {
|
|
210
|
+
const p = this.env.socketPath;
|
|
211
|
+
if (process.platform !== 'win32') return p;
|
|
212
|
+
return p.startsWith('\\\\.\\pipe\\') ? p : `\\\\.\\pipe\\${p}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 控制请求:一连接一请求(server 应答后关连接,实测协议行为)。
|
|
217
|
+
* 读满一行响应即完成;响应是 {id, result} | {id, error}。
|
|
218
|
+
*/
|
|
219
|
+
private request(method: string, params: Record<string, unknown>, timeoutMs = 15000): Promise<unknown> {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const sock = net.createConnection(this.target());
|
|
222
|
+
sock.setEncoding('utf8');
|
|
223
|
+
let buf = '';
|
|
224
|
+
let settled = false;
|
|
225
|
+
const timer = setTimeout(() => {
|
|
226
|
+
if (!settled) {
|
|
227
|
+
settled = true;
|
|
228
|
+
sock.destroy();
|
|
229
|
+
reject(new Error(`herdr ${method}: timeout`));
|
|
230
|
+
}
|
|
231
|
+
}, timeoutMs);
|
|
232
|
+
sock.on('connect', () => {
|
|
233
|
+
sock.write(JSON.stringify({ id: '1', method, params }) + '\n', (err) => {
|
|
234
|
+
if (err && !settled) {
|
|
235
|
+
settled = true;
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
reject(err);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
sock.on('data', (chunk) => {
|
|
242
|
+
buf += chunk;
|
|
243
|
+
const idx = buf.indexOf('\n');
|
|
244
|
+
if (idx < 0) return;
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
if (settled) return;
|
|
247
|
+
settled = true;
|
|
248
|
+
sock.destroy();
|
|
249
|
+
let msg: { id?: string; result?: unknown; error?: { code?: string; message?: string } };
|
|
250
|
+
try {
|
|
251
|
+
msg = JSON.parse(buf.slice(0, idx).trim());
|
|
252
|
+
} catch {
|
|
253
|
+
reject(new Error(`herdr ${method}: bad frame`));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (msg.error) reject(new Error(`${msg.error.code ?? 'error'}: ${msg.error.message ?? ''}`));
|
|
257
|
+
else resolve(msg.result);
|
|
258
|
+
});
|
|
259
|
+
sock.on('error', (err) => {
|
|
260
|
+
if (settled) return;
|
|
261
|
+
settled = true;
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
reject(err);
|
|
264
|
+
});
|
|
265
|
+
sock.on('close', () => {
|
|
266
|
+
if (!settled) {
|
|
267
|
+
settled = true;
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
reject(new Error(`herdr ${method}: connection closed`));
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ── 上报类(失败静默:工作台镜像尽力而为,绝不影响 pi 主流程) ── */
|
|
276
|
+
|
|
277
|
+
async reportAgent(state: PaneAgentState, message: string | null): Promise<void> {
|
|
278
|
+
try {
|
|
279
|
+
await this.request('pane.report_agent', {
|
|
280
|
+
pane_id: this.env.paneId,
|
|
281
|
+
source: REPORT_AGENT_SOURCE,
|
|
282
|
+
agent: 'pi',
|
|
283
|
+
state,
|
|
284
|
+
...(message ? { message } : {}),
|
|
285
|
+
});
|
|
286
|
+
} catch {
|
|
287
|
+
/* 静默 */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async reportAgentSession(sessionPath: string | null): Promise<void> {
|
|
292
|
+
if (!sessionPath) return;
|
|
293
|
+
try {
|
|
294
|
+
await this.request('pane.report_agent_session', {
|
|
295
|
+
pane_id: this.env.paneId,
|
|
296
|
+
source: REPORT_AGENT_SOURCE,
|
|
297
|
+
agent: 'pi',
|
|
298
|
+
agent_session_path: sessionPath,
|
|
299
|
+
});
|
|
300
|
+
} catch {
|
|
301
|
+
/* 静默 */
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async reportMetadata(meta: { session: string; items: TodoItem[]; progressSuffix?: string | null }): Promise<void> {
|
|
306
|
+
try {
|
|
307
|
+
void meta.session;
|
|
308
|
+
const title = formatPaneTitle(meta.items, null, { progressSuffix: meta.progressSuffix });
|
|
309
|
+
const blocked = formatBlockedLabel(meta.items);
|
|
310
|
+
// D96:stale 清理与日常上报分离——stale(16) + pi-todo(1) = 17 > herdr tokens max=16
|
|
311
|
+
// → 整个请求被拒 → title/tokens 全丢(D93 回归根因)。stale 单独批次,成功后才置位。
|
|
312
|
+
if (!this.clearedStaleTokens) {
|
|
313
|
+
await this.request('pane.report_metadata', {
|
|
314
|
+
pane_id: this.env.paneId,
|
|
315
|
+
source: REPORT_AGENT_SOURCE,
|
|
316
|
+
tokens: staleTokenClearance(),
|
|
317
|
+
ttl_ms: 86400000,
|
|
318
|
+
});
|
|
319
|
+
this.clearedStaleTokens = true;
|
|
320
|
+
}
|
|
321
|
+
await this.request('pane.report_metadata', {
|
|
322
|
+
pane_id: this.env.paneId,
|
|
323
|
+
source: REPORT_AGENT_SOURCE,
|
|
324
|
+
...(title ? { title } : { clear_title: true }),
|
|
325
|
+
...(blocked
|
|
326
|
+
? { state_labels: { [BLOCKED_LABEL_KEY]: blocked } }
|
|
327
|
+
: { clear_state_labels: true }),
|
|
328
|
+
// D93:todo 摘要进 custom token(空串 = 清键,无 todo 不留旧摘要)
|
|
329
|
+
tokens: sidebarTodoTokens(title),
|
|
330
|
+
ttl_ms: 86400000,
|
|
331
|
+
});
|
|
332
|
+
} catch {
|
|
333
|
+
/* 静默:标题投影尽力而为,绝不影响 pi 主流程 */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async reportLockTokens(tokens: Record<string, string | null>): Promise<void> {
|
|
338
|
+
const keys = Object.keys(tokens);
|
|
339
|
+
if (keys.length === 0) return;
|
|
340
|
+
try {
|
|
341
|
+
// schema maxProperties=16 → 分批(每批独立请求,全部尽力而为)
|
|
342
|
+
for (let i = 0; i < keys.length; i += LOCK_BATCH_LIMIT) {
|
|
343
|
+
const batch: Record<string, string | null> = {};
|
|
344
|
+
for (const k of keys.slice(i, i + LOCK_BATCH_LIMIT)) batch[k] = tokens[k];
|
|
345
|
+
await this.request('pane.report_metadata', {
|
|
346
|
+
pane_id: this.env.paneId,
|
|
347
|
+
source: REPORT_AGENT_SOURCE,
|
|
348
|
+
tokens: batch,
|
|
349
|
+
ttl_ms: LOCK_TTL_MS,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
/* 锁登记尽力而为(软 veto 下失败 = 少一次警告,不阻断) */
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async reportDisplayAgent(name: string | null): Promise<void> {
|
|
358
|
+
try {
|
|
359
|
+
await this.request('pane.report_metadata', {
|
|
360
|
+
pane_id: this.env.paneId,
|
|
361
|
+
source: REPORT_AGENT_SOURCE,
|
|
362
|
+
...(name ? { display_agent: name } : { clear_display_agent: true }),
|
|
363
|
+
});
|
|
364
|
+
} catch {
|
|
365
|
+
/* 静默:侧边栏标识尽力而为 */
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async reportAskFlag(text: string | null): Promise<void> {
|
|
370
|
+
try {
|
|
371
|
+
await this.request('pane.report_metadata', {
|
|
372
|
+
pane_id: this.env.paneId,
|
|
373
|
+
source: REPORT_AGENT_SOURCE,
|
|
374
|
+
tokens: { [SIDEBAR_ASK_TOKEN]: text ?? '' },
|
|
375
|
+
ttl_ms: 86400000,
|
|
376
|
+
});
|
|
377
|
+
} catch {
|
|
378
|
+
/* 静默:人类闸门标志尽力而为 */
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ── 查询与控制 ───────────────────────────────────────────────────── */
|
|
383
|
+
|
|
384
|
+
async listAgents(): Promise<AgentInfo[]> {
|
|
385
|
+
const result = (await this.request('agent.list', {})) as {
|
|
386
|
+
type?: string;
|
|
387
|
+
agents?: Array<Record<string, unknown>>;
|
|
388
|
+
} | null;
|
|
389
|
+
const arr = result?.agents ?? [];
|
|
390
|
+
return arr.map((a) => {
|
|
391
|
+
const session = a.agent_session as { value?: unknown; kind?: unknown } | null | undefined;
|
|
392
|
+
return {
|
|
393
|
+
paneId: String(a.pane_id ?? ''),
|
|
394
|
+
agent: a.agent ? String(a.agent) : null,
|
|
395
|
+
status: String(a.agent_status ?? 'unknown') as HerdrAgentState,
|
|
396
|
+
session: session?.value != null ? String(session.value) : null,
|
|
397
|
+
stateLabels: (a.state_labels ?? {}) as Record<string, string>,
|
|
398
|
+
tokens: (a.tokens ?? {}) as Record<string, string | null>,
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async spawnSubPane(opts: { label: string; command: string[]; cwd: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }> {
|
|
404
|
+
const result = (await this.request('layout.apply', {
|
|
405
|
+
tab_label: opts.label,
|
|
406
|
+
root: { type: 'pane', command: opts.command, cwd: opts.cwd, ...(opts.env ? { env: opts.env } : {}) },
|
|
407
|
+
})) as Record<string, unknown>;
|
|
408
|
+
const paneId = findIdIn(result, 'pane_id');
|
|
409
|
+
if (!paneId) throw new Error('layout.apply: no pane_id in response');
|
|
410
|
+
return { tabId: findIdIn(result, 'tab_id') ?? '', paneId };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async sendPaneText(paneId: string, text: string): Promise<void> {
|
|
414
|
+
// 尾部 \r 即 Enter(实测:文本进入 pi 编辑器并提交)
|
|
415
|
+
await this.request('pane.send_text', { pane_id: paneId, text: text + '\r' });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async waitAgent(paneId: string, until: HerdrAgentState[], timeoutMs: number): Promise<HerdrAgentState | null> {
|
|
419
|
+
try {
|
|
420
|
+
const result = (await this.request('agent.wait', {
|
|
421
|
+
target: paneId,
|
|
422
|
+
until,
|
|
423
|
+
timeout_ms: timeoutMs,
|
|
424
|
+
}, timeoutMs + 5000)) as { agent?: { agent_status?: string } } | { type?: string; agent?: { agent_status?: string } } | null;
|
|
425
|
+
const status = result?.agent?.agent_status ?? (result as { agent_status?: string })?.agent_status;
|
|
426
|
+
return (typeof status === 'string' ? status : 'unknown') as HerdrAgentState;
|
|
427
|
+
} catch (err) {
|
|
428
|
+
const msg = String((err as Error)?.message ?? err);
|
|
429
|
+
if (/timeout/i.test(msg)) return null;
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async getAgentSessionPath(paneId: string): Promise<string | null> {
|
|
435
|
+
const agents = await this.listAgents();
|
|
436
|
+
const a = agents.find((x) => x.paneId === paneId);
|
|
437
|
+
return a?.session ?? null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async focusPane(paneId: string): Promise<void> {
|
|
441
|
+
await this.request('pane.focus', { pane_id: paneId });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async splitPane(opts: { direction?: 'left' | 'right' | 'up' | 'down'; cwd?: string; env?: Record<string, string>; targetPaneId?: string } = {}): Promise<string> {
|
|
445
|
+
const result = (await this.request('pane.split', {
|
|
446
|
+
direction: opts.direction ?? 'right',
|
|
447
|
+
...(opts.targetPaneId ? { target_pane_id: opts.targetPaneId } : {}),
|
|
448
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
449
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
450
|
+
})) as Record<string, unknown>;
|
|
451
|
+
const paneId = findIdIn(result, 'pane_id');
|
|
452
|
+
if (!paneId) throw new Error('pane.split: no pane_id in response');
|
|
453
|
+
return paneId;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async closePane(paneId: string): Promise<void> {
|
|
457
|
+
await this.request('pane.close', { pane_id: paneId });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async createTab(opts: { workspaceId: string; label?: string; cwd?: string; env?: Record<string, string> }): Promise<{ tabId: string; paneId: string }> {
|
|
461
|
+
const result = (await this.request('tab.create', {
|
|
462
|
+
workspace_id: opts.workspaceId,
|
|
463
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
464
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
465
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
466
|
+
})) as Record<string, unknown>;
|
|
467
|
+
const paneId = findIdIn(result, 'pane_id');
|
|
468
|
+
const tabId = findIdIn(result, 'tab_id');
|
|
469
|
+
if (!tabId) throw new Error('tab.create: no tab_id in response');
|
|
470
|
+
return { tabId, paneId: paneId ?? '' };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async listPanes(): Promise<Array<{ paneId: string; tabId: string; workspaceId: string; agentStatus: string }>> {
|
|
474
|
+
const result = (await this.request('pane.list', {})) as { panes?: Array<Record<string, unknown>> } | null;
|
|
475
|
+
return (result?.panes ?? []).map((p) => ({
|
|
476
|
+
paneId: String(p.pane_id ?? ''),
|
|
477
|
+
tabId: String(p.tab_id ?? ''),
|
|
478
|
+
workspaceId: String(p.workspace_id ?? ''),
|
|
479
|
+
agentStatus: String(p.agent_status ?? 'unknown'),
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async exportLayout(opts: { paneId?: string; tabId?: string }): Promise<{ tabId: string | null; zoomed: boolean; root: unknown } | null> {
|
|
484
|
+
try {
|
|
485
|
+
const result = (await this.request('layout.export', {
|
|
486
|
+
...(opts.paneId ? { pane_id: opts.paneId } : {}),
|
|
487
|
+
...(opts.tabId ? { tab_id: opts.tabId } : {}),
|
|
488
|
+
})) as Record<string, unknown> | null;
|
|
489
|
+
// 响应信封:{layout:{tab_id,zoomed,root}};兼容 root 直接在顶层
|
|
490
|
+
const layout = (result?.layout ?? result) as Record<string, unknown> | null | undefined;
|
|
491
|
+
if (!layout || typeof layout !== 'object' || !('root' in layout)) return null;
|
|
492
|
+
return {
|
|
493
|
+
tabId: typeof layout.tab_id === 'string' ? layout.tab_id : null,
|
|
494
|
+
zoomed: Boolean(layout.zoomed),
|
|
495
|
+
root: layout.root ?? null,
|
|
496
|
+
};
|
|
497
|
+
} catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private mapTabInfo(raw: Record<string, unknown> | null | undefined): TabInfo | null {
|
|
503
|
+
if (!raw || typeof raw.tab_id !== 'string') return null;
|
|
504
|
+
return {
|
|
505
|
+
tabId: raw.tab_id,
|
|
506
|
+
workspaceId: String(raw.workspace_id ?? ''),
|
|
507
|
+
label: String(raw.label ?? ''),
|
|
508
|
+
paneCount: Number(raw.pane_count ?? 0),
|
|
509
|
+
agentStatus: String(raw.agent_status ?? 'unknown'),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async tabList(): Promise<TabInfo[]> {
|
|
514
|
+
const result = (await this.request('tab.list', {})) as { tabs?: Array<Record<string, unknown>> } | null;
|
|
515
|
+
return (result?.tabs ?? []).map((t) => this.mapTabInfo(t)).filter((t): t is TabInfo => t !== null);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async tabGet(tabId: string): Promise<TabInfo | null> {
|
|
519
|
+
const result = (await this.request('tab.get', { tab_id: tabId })) as { tab?: Record<string, unknown> | null } | null;
|
|
520
|
+
return this.mapTabInfo(result?.tab ?? null);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async tabClose(tabId: string): Promise<void> {
|
|
524
|
+
await this.request('tab.close', { tab_id: tabId });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async sendPaneKeys(paneId: string, keys: string[]): Promise<void> {
|
|
528
|
+
await this.request('pane.send_keys', { pane_id: paneId, keys });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async readPane(paneId: string, opts: {
|
|
532
|
+
source?: 'visible' | 'recent' | 'recent_unwrapped';
|
|
533
|
+
lines?: number;
|
|
534
|
+
stripAnsi?: boolean;
|
|
535
|
+
} = {}): Promise<{ text: string; revision: number; truncated: boolean }> {
|
|
536
|
+
// 信封实测:{type:'pane_read', read:{text, revision, truncated, ...}}(payload 套在 read 下)
|
|
537
|
+
const result = (await this.request('pane.read', {
|
|
538
|
+
pane_id: paneId,
|
|
539
|
+
source: opts.source ?? 'recent',
|
|
540
|
+
format: 'text',
|
|
541
|
+
strip_ansi: opts.stripAnsi ?? false,
|
|
542
|
+
...(opts.lines != null ? { lines: opts.lines } : {}),
|
|
543
|
+
})) as { read?: { text?: unknown; revision?: unknown; truncated?: unknown }; text?: unknown; revision?: unknown; truncated?: unknown } | null;
|
|
544
|
+
const payload = result?.read ?? result ?? {};
|
|
545
|
+
return {
|
|
546
|
+
text: typeof payload.text === 'string' ? payload.text : '',
|
|
547
|
+
revision: typeof payload.revision === 'number' ? payload.revision : 0,
|
|
548
|
+
truncated: payload.truncated === true,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async waitForOutput(paneId: string, match: { type: 'substring' | 'regex'; value: string }, timeoutMs: number): Promise<boolean | null> {
|
|
553
|
+
try {
|
|
554
|
+
await this.request('pane.wait_for_output', {
|
|
555
|
+
pane_id: paneId,
|
|
556
|
+
source: 'recent',
|
|
557
|
+
match,
|
|
558
|
+
timeout_ms: timeoutMs,
|
|
559
|
+
}, timeoutMs + 5000);
|
|
560
|
+
return true;
|
|
561
|
+
} catch (err) {
|
|
562
|
+
const msg = String((err as Error)?.message ?? err);
|
|
563
|
+
if (/timeout/i.test(msg)) return null;
|
|
564
|
+
throw err;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
close(): void {
|
|
569
|
+
/* v1.1 无长驻连接;保留接口语义(Noop) */
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function createHerdrClient(
|
|
574
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
575
|
+
): { client: HerdrClientLike; env: HerdrEnv | null } {
|
|
576
|
+
const detected = detectHerdrEnv(env);
|
|
577
|
+
if (!detected) return { client: new NoopHerdrClient(), env: null };
|
|
578
|
+
return { client: new HerdrClient(detected), env: detected };
|
|
579
|
+
}
|