@tea-agent/loop-agent 0.34.3 → 0.34.4
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/AGENTS.md +7 -2
- package/CHANGELOG.md +50 -22
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +85 -3
- package/dist/worker/console/chat/pi-runtime.js +230 -62
- package/dist/worker/console/chat/resource-preferences-store.js +152 -0
- package/dist/worker/console/chat/routes.js +401 -18
- package/dist/worker/console/chat/turn-execution-registry.js +82 -0
- package/dist/worker/console/dag-execution-receipt.js +14 -1
- package/dist/worker/console/server.js +4 -0
- package/dist/worker/console/static/assets/index-B6Qdbk8V.js +29 -0
- package/dist/worker/console/static/assets/index-Bt0NUxcQ.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/refs.js +24 -0
- package/dist/worker/console/static-src/operator-chat/resource-auto-invocation.js +91 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +690 -0
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +158 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +535 -86
- package/dist/workflows/dag/init-hybrid.js +2 -0
- package/docs/operations/local-development-environment.md +4 -2
- package/docs/templates/branch-merge-report.md +9 -0
- package/package.json +3 -2
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +0 -29
package/AGENTS.md
CHANGED
|
@@ -74,7 +74,9 @@ DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件
|
|
|
74
74
|
|
|
75
75
|
权威源:`docs/governance/verification-matrix.md`。
|
|
76
76
|
|
|
77
|
-
默认节奏:编辑中只跑矩阵「最低验证」(typecheck + 定向 Vitest);`git commit` 的 `pre-commit` 只做 `check-repo`;`git push` 的 `pre-push`
|
|
77
|
+
默认节奏:编辑中只跑矩阵「最低验证」(typecheck + 定向 Vitest);`git commit` 的 `pre-commit` 只做 `check-repo`;`git push` 的 `pre-push` 经 `node scripts/pre-push-verify.mjs` 在 receipt 命中时复用、miss 时跑 full `bash scripts/ci.sh`(全量 typecheck + `npm test`)。每个 clone 安装一次 hooks:`bash scripts/install-git-hooks.sh`。
|
|
78
|
+
|
|
79
|
+
连续 source→target 交付遵循**单一最终树全量验证**:同一 Git tree、相同环境与命令合同在 receipt TTL 内只选一个本地 full authority(有效 receipt / target pre-push / 可选 `npm run verify:tree`),不按分支名策略化,也不机械双跑 full CI。细节见 verification-matrix 与 branch-merge-guideline。
|
|
78
80
|
|
|
79
81
|
常用:
|
|
80
82
|
|
|
@@ -83,7 +85,10 @@ DAG 执行期间,主 agent 不得修改 writeSet 外的任何工作区文件
|
|
|
83
85
|
npm run typecheck
|
|
84
86
|
npx vitest run <相关测试路径>
|
|
85
87
|
|
|
86
|
-
#
|
|
88
|
+
# 可选:提前验证当前 clean HEAD 并写 receipt(随后同 tree push 可复用)
|
|
89
|
+
npm run verify:tree
|
|
90
|
+
|
|
91
|
+
# push / 交付前(或依赖 pre-push;receipt miss 时安全回退 full)
|
|
87
92
|
bash scripts/ci.sh
|
|
88
93
|
npm run build
|
|
89
94
|
node bin/loop-agent.js --help
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.34.4] - 2026-08-12
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 本地连续交付改为按精确 Git tree 复用验证结果,避免提交、推送和分支合并之间机械重复全量测试,同时保持不同 tree、不同环境和远程 CI 的独立质量门槛
|
|
10
|
+
- 全面加固 Operator Chat 的 Turn 所有权、停止与异常恢复流程,解决浏览器与服务端状态错位时的重复提交、停止失效和错误提示问题
|
|
11
|
+
- Operator Chat 新增按仓库保存的 Skills 自动使用偏好,用户可控制技能是否进入模型自动发现范围,同时保留手动调用能力
|
|
12
|
+
- 修复控制台任务导航无法自主执行后端测试与前端测试 DAG 的问题,现可正确识别 pytest、playwright 等多种测试框架的验证命令
|
|
13
|
+
|
|
14
|
+
### 新增
|
|
15
|
+
|
|
16
|
+
- 新增可选的 `npm run verify:tree`,对当前严格 clean HEAD 执行完整验证并写入 receipt,随后推送同一 tree 时可直接复用
|
|
17
|
+
- 新增只读的当前 tree receipt 诊断,可区分匹配、缺失、过期、损坏和工作区不合格等状态,不执行测试或写入 receipt
|
|
18
|
+
- Operator Chat 引入完整的 Turn ownership 协议和服务端真实中止接口,并在状态接口中提供 active/latest Turn 与 runtime busy 事实
|
|
19
|
+
- Operator Chat Skills 新增「允许自动使用」开关,偏好按仓库持久化;关闭后仍可通过 `/skill:<name>` 手动调用
|
|
20
|
+
|
|
21
|
+
### 改进
|
|
22
|
+
|
|
23
|
+
- source→target 连续交付统一选择一个本地全量验证权威;默认 commit/push 不增加步骤,远程 CI 与发布环境不复用本地 receipt
|
|
24
|
+
- pre-push receipt 诊断和执行日志改用有界命令摘要,并严格校验 tree、环境、命令合同、有效期及 receipt 内容一致性
|
|
25
|
+
- Skills 运行时快照改为使用服务端资源标识和独立的自动调用状态,资源变更仅接受当前 inventory 中的合法条目
|
|
26
|
+
- Turn 已占用时返回可恢复的中文状态提示和真实 active Turn,不再向用户暴露裸露的 `chat turn already active` 错误
|
|
27
|
+
|
|
28
|
+
### 修复
|
|
29
|
+
|
|
30
|
+
- 修复浏览器已认为对话结束、服务端却仍持有 active Turn 时的重复提交竞态;提交现在支持幂等请求标识、boot epoch orphan 隔离和 durable Turn/runtime busy 联合对账
|
|
31
|
+
- 修复创建 Turn 的响应不确定时状态被过早释放的问题;客户端会保留 ambiguous 状态,通过 `/state` 绑定或释放真实 ownership,不会重新生成请求标识
|
|
32
|
+
- 修复停止请求在 Turn 尚未绑定时丢失的问题;绑定成功后只发送一次真实 abort,且服务端缺少 abort 能力时不会通过 dispose 会话伪造停止
|
|
33
|
+
- 修复 active bind、terminal SSE 与 React streaming 状态交错时提交锁未释放的问题,并统一 adopt、停止中和 abort-pending 为非致命提示
|
|
34
|
+
- 修复控制台任务导航执行 backend-test 和 frontend-test DAG 时,因结构化验证门禁未识别 pytest/playwright 等命令而始终被阻断的问题
|
|
35
|
+
|
|
5
36
|
## [0.34.3] - 2026-08-12
|
|
6
37
|
|
|
7
38
|
### 重点更新
|
|
@@ -85,36 +116,33 @@
|
|
|
85
116
|
|
|
86
117
|
## [0.33.6] - 2026-08-11
|
|
87
118
|
|
|
119
|
+
### 重点更新
|
|
120
|
+
|
|
121
|
+
- 加固 PRD 导入与 Semantic Intake,在结构化解析不足时提供安全回退,并增强 writeSet 审查与运行结果摘要
|
|
122
|
+
- Operator Chat 接入单一事件流、启动 epoch 隔离、实时运行时控制与开放资源加载,提升长会话恢复和自主 DAG 监督能力
|
|
123
|
+
- 统一 Windows 等环境下只读 Git 命令的有界恢复机制,同时保留用户自有的 Pi 模型路由配置
|
|
124
|
+
|
|
88
125
|
### 新增
|
|
89
126
|
|
|
90
|
-
-
|
|
91
|
-
- Operator Chat
|
|
92
|
-
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
- reconcile(cursor_expired) 自动恢复而非全局致命错误
|
|
96
|
-
- 接入 Operator Chat 实时运行时控制
|
|
97
|
-
- open operator chat resource loading
|
|
98
|
-
- enrich execution report and URL resolution
|
|
127
|
+
- task advance 新增 writeSet delta 与 run summary,直接展示边界扩张来源、节点结果和下一步操作
|
|
128
|
+
- Operator Chat 引入单一事件流与 boot epoch fence,支持 cursor 过期后的自动对账恢复,避免将可恢复问题升级为全局错误
|
|
129
|
+
- ChatEventStore 增加事件合并与容量上限双重压缩,控制长期会话持久化体积
|
|
130
|
+
- Operator Chat 新增实时模型、thinking 和运行时资源控制,并按开放资源加载规则解析 Skills、Extensions 与 Packages
|
|
131
|
+
- Operator Chat 可对安全的 bounded DAG 进行持续监督和恢复,执行报告同步补充运行细节与目标 URL 来源
|
|
99
132
|
|
|
100
133
|
### 改进
|
|
101
134
|
|
|
102
|
-
-
|
|
103
|
-
-
|
|
135
|
+
- 只读 Git 查询统一使用共享韧性执行器,在 Windows 启动型故障下有界恢复,不扩大 mutation 命令权限
|
|
136
|
+
- 初始化与升级流程保留用户自行配置的 Pi 模型路由,不再因默认配置变化机械覆盖
|
|
137
|
+
- Packages 面板区分配置状态与真实安装元数据,并统一使用品牌图标
|
|
104
138
|
|
|
105
139
|
### 修复
|
|
106
140
|
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
- split rerun defaults and repair selector escaping
|
|
113
|
-
- ignore delimiters inside pytest strings
|
|
114
|
-
- resolve target URL from runtime context
|
|
115
|
-
- reject priority-only module stems
|
|
116
|
-
- stop desiredSha256 mechanical checks from blocking generated-content upgrades
|
|
117
|
-
- use mobius header brand icon
|
|
141
|
+
- 修复 Operator Chat 流式状态与 pi-web 可靠性机制未完全对齐的问题,提升重连和终态收敛稳定性
|
|
142
|
+
- 修复前端测试环境预检中的反引号转义错误,以及重跑默认值和 repair 选择器转义不一致的问题
|
|
143
|
+
- 修复后端测试完整性检查误判字符串内部未配对分隔符,以及按优先级生成非法单模块文件的问题
|
|
144
|
+
- 修复前端测试目标 URL 未从运行时上下文解析的问题,执行时现使用可审计的真实地址来源
|
|
145
|
+
- 修复 generated init 内容因 `desiredSha256` 机械比较而阻断安全升级的问题
|
|
118
146
|
|
|
119
147
|
## [0.33.5] - 2026-08-11
|
|
120
148
|
|
|
@@ -43,6 +43,7 @@ export function openConsoleAppData(options) {
|
|
|
43
43
|
chats: path.join(repoNamespace, "chats"),
|
|
44
44
|
chatSessions: path.join(repoNamespace, "chat-sessions"),
|
|
45
45
|
chatEvents: path.join(repoNamespace, "chat-events"),
|
|
46
|
+
resourcePreferences: path.join(repoNamespace, "resource-preferences.json"),
|
|
46
47
|
};
|
|
47
48
|
for (const dir of [
|
|
48
49
|
paths.drafts,
|
|
@@ -55,6 +56,7 @@ export function openConsoleAppData(options) {
|
|
|
55
56
|
paths.chats,
|
|
56
57
|
paths.chatSessions,
|
|
57
58
|
paths.chatEvents,
|
|
59
|
+
path.dirname(paths.resourcePreferences),
|
|
58
60
|
]) {
|
|
59
61
|
ensureSecureDir(dir);
|
|
60
62
|
}
|
|
@@ -221,6 +221,20 @@ function shouldCoalesce(ring, turnId, partial) {
|
|
|
221
221
|
return false;
|
|
222
222
|
return nowMs - Date.parse(tail.at) <= ring.coalesceWindowMs;
|
|
223
223
|
}
|
|
224
|
+
/** Stable hash of the normalized prompt payload for clientRequestId binding. */
|
|
225
|
+
export function hashChatTurnPayload(input) {
|
|
226
|
+
const normalized = {
|
|
227
|
+
text: input.text,
|
|
228
|
+
images: (input.images ?? []).map((image) => ({
|
|
229
|
+
type: image.type ?? "image",
|
|
230
|
+
mimeType: image.mimeType ?? "",
|
|
231
|
+
data: image.data ?? "",
|
|
232
|
+
})),
|
|
233
|
+
};
|
|
234
|
+
return createHash("sha256")
|
|
235
|
+
.update(JSON.stringify(normalized))
|
|
236
|
+
.digest("hex");
|
|
237
|
+
}
|
|
224
238
|
const DEFAULT_MAX_EVENTS = 2000;
|
|
225
239
|
/** Mirrors pi-web's bounded-ring orientation (2000 events / 10 MiB). */
|
|
226
240
|
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
@@ -399,6 +413,24 @@ export function createChatEventStore(options) {
|
|
|
399
413
|
eventId: eventIdFor(sessionId, event.seq),
|
|
400
414
|
}));
|
|
401
415
|
const turns = Array.isArray(parsed.turns) ? parsed.turns : [];
|
|
416
|
+
// New Console epoch: orphan any queued/running turn whose ownerEpoch is
|
|
417
|
+
// missing or mismatched. Releases the active lease without pretending the
|
|
418
|
+
// user aborted (CHAT_TURN_OWNER_LOST fence).
|
|
419
|
+
let orphaned = false;
|
|
420
|
+
const now = new Date().toISOString();
|
|
421
|
+
for (const turn of turns) {
|
|
422
|
+
if (turn.state !== "queued" && turn.state !== "running")
|
|
423
|
+
continue;
|
|
424
|
+
if (turn.ownerEpoch === eventEpoch)
|
|
425
|
+
continue;
|
|
426
|
+
turn.state = "failed";
|
|
427
|
+
turn.finishedAt = now;
|
|
428
|
+
turn.error = {
|
|
429
|
+
code: "CHAT_TURN_OWNER_LOST",
|
|
430
|
+
message: "turn ownership lost after Console restart; previous execution is no longer owned",
|
|
431
|
+
};
|
|
432
|
+
orphaned = true;
|
|
433
|
+
}
|
|
402
434
|
turnCounts.set(sessionId, parsed.turnCount ?? 0);
|
|
403
435
|
const ring = {
|
|
404
436
|
events,
|
|
@@ -415,6 +447,8 @@ export function createChatEventStore(options) {
|
|
|
415
447
|
// R6: re-check the bounds after a restart — a persisted file may exceed the
|
|
416
448
|
// current limits if it was written by an older config or hand-edited.
|
|
417
449
|
enforceBounds(sessionId, ring);
|
|
450
|
+
if (orphaned)
|
|
451
|
+
persist(sessionId, ring);
|
|
418
452
|
return ring;
|
|
419
453
|
}
|
|
420
454
|
catch (error) {
|
|
@@ -521,11 +555,35 @@ export function createChatEventStore(options) {
|
|
|
521
555
|
}
|
|
522
556
|
return { copiedCount };
|
|
523
557
|
},
|
|
524
|
-
createTurn(sessionId) {
|
|
558
|
+
createTurn(sessionId, input) {
|
|
525
559
|
const ring = ringFor(sessionId);
|
|
560
|
+
const clientRequestId = input?.clientRequestId?.trim() || undefined;
|
|
561
|
+
const payloadHash = input?.payloadHash?.trim() || undefined;
|
|
562
|
+
if (clientRequestId) {
|
|
563
|
+
const existing = ring.turns.find((turn) => turn.clientRequestId === clientRequestId);
|
|
564
|
+
if (existing) {
|
|
565
|
+
if (payloadHash &&
|
|
566
|
+
existing.payloadHash &&
|
|
567
|
+
existing.payloadHash !== payloadHash) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
code: "REQUEST_ID_REUSE_CONFLICT",
|
|
571
|
+
message: `clientRequestId ${clientRequestId} was reused with a different payload`,
|
|
572
|
+
existingTurn: { ...existing },
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
// Same id + same (or missing) payload: idempotent return, no new lease.
|
|
576
|
+
return {
|
|
577
|
+
ok: true,
|
|
578
|
+
turn: { ...existing },
|
|
579
|
+
created: false,
|
|
580
|
+
idempotent: true,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
526
584
|
const active = ring.turns.find((turn) => turn.state === "queued" || turn.state === "running");
|
|
527
585
|
if (active)
|
|
528
|
-
return { ok: false, code: "TURN_ACTIVE", activeTurn: active };
|
|
586
|
+
return { ok: false, code: "TURN_ACTIVE", activeTurn: { ...active } };
|
|
529
587
|
const ordinal = (turnCounts.get(sessionId) ?? 0) + 1;
|
|
530
588
|
turnCounts.set(sessionId, ordinal);
|
|
531
589
|
const now = new Date().toISOString();
|
|
@@ -536,11 +594,14 @@ export function createChatEventStore(options) {
|
|
|
536
594
|
ordinal,
|
|
537
595
|
state: "queued",
|
|
538
596
|
createdAt: now,
|
|
597
|
+
ownerEpoch: eventEpoch,
|
|
598
|
+
...(clientRequestId ? { clientRequestId } : {}),
|
|
599
|
+
...(payloadHash ? { payloadHash } : {}),
|
|
539
600
|
};
|
|
540
601
|
ring.turns.push(turn);
|
|
541
602
|
ring.approxBytes += byteHint(turn);
|
|
542
603
|
persist(sessionId, ring);
|
|
543
|
-
return { ok: true, turn };
|
|
604
|
+
return { ok: true, turn: { ...turn }, created: true, idempotent: false };
|
|
544
605
|
},
|
|
545
606
|
setTurnState(sessionId, turnId, state, error) {
|
|
546
607
|
const ring = ringFor(sessionId);
|
|
@@ -561,6 +622,27 @@ export function createChatEventStore(options) {
|
|
|
561
622
|
persist(sessionId, ring);
|
|
562
623
|
return { ...turn };
|
|
563
624
|
},
|
|
625
|
+
markAbortRequested(sessionId, turnId, at) {
|
|
626
|
+
const ring = ringFor(sessionId);
|
|
627
|
+
const turn = ring.turns.find((candidate) => candidate.turnId === turnId);
|
|
628
|
+
if (!turn)
|
|
629
|
+
return undefined;
|
|
630
|
+
const before = JSON.stringify(turn).length;
|
|
631
|
+
if (!turn.abortRequestedAt)
|
|
632
|
+
turn.abortRequestedAt = at ?? new Date().toISOString();
|
|
633
|
+
ring.approxBytes +=
|
|
634
|
+
(JSON.stringify(turn).length - before) * BYTE_GATE_SCALE;
|
|
635
|
+
persist(sessionId, ring);
|
|
636
|
+
return { ...turn };
|
|
637
|
+
},
|
|
638
|
+
findTurnByClientRequestId(sessionId, clientRequestId) {
|
|
639
|
+
const id = clientRequestId.trim();
|
|
640
|
+
if (!id)
|
|
641
|
+
return undefined;
|
|
642
|
+
const ring = ringFor(sessionId);
|
|
643
|
+
const turn = ring.turns.find((candidate) => candidate.clientRequestId === id);
|
|
644
|
+
return turn ? { ...turn } : undefined;
|
|
645
|
+
},
|
|
564
646
|
getTurn(sessionId, turnId) {
|
|
565
647
|
const ring = ringFor(sessionId);
|
|
566
648
|
const turn = ring.turns.find((candidate) => candidate.turnId === turnId);
|
|
@@ -32,6 +32,7 @@ import { projectCompactSnapshot, } from "./chat-event-store.js";
|
|
|
32
32
|
import { extractUsageSample } from "./usage.js";
|
|
33
33
|
import { OPERATOR_CHAT_ALLOWED_TOOLS, authorizeOperatorChatTool, assertNoWriteToolInList, } from "./tools.js";
|
|
34
34
|
import { createOperatorChatResourceLoader, } from "./resource-loader.js";
|
|
35
|
+
import { applySkillAutoInvocationPreferences, isAutoInvocationEnabled, loadResourcePreferences, skillResourceId, } from "./resource-preferences-store.js";
|
|
35
36
|
import { buildModelCallableToolSchemas } from "./tool-adapter.js";
|
|
36
37
|
import { resolveDefaultChatModel, resolveLowChatModel, } from "./model-resolver.js";
|
|
37
38
|
import { filterActiveInterviewTools } from "../interview/tools.js";
|
|
@@ -466,7 +467,8 @@ export function projectRuntimeSnapshot(input) {
|
|
|
466
467
|
...(skill.baseDir ? { baseDir: field(skill.baseDir, 400) } : {}),
|
|
467
468
|
scope: skill.scope,
|
|
468
469
|
source: field(skill.source, 200) ?? "",
|
|
469
|
-
|
|
470
|
+
resourceId: field(skill.resourceId, 200) ?? "",
|
|
471
|
+
autoInvocationEnabled: skill.autoInvocationEnabled,
|
|
470
472
|
diagnostics: skill.diagnostics.map((diag) => field(diag, 400) ?? ""),
|
|
471
473
|
});
|
|
472
474
|
const projectExtension = (ext) => ({
|
|
@@ -645,6 +647,8 @@ export class ConsolePiRuntime {
|
|
|
645
647
|
revisions = new Map();
|
|
646
648
|
/** RF-01: per-session reload in-flight lock (concurrent second call → PI_SESSION_BUSY). */
|
|
647
649
|
reloadInFlight = new Set();
|
|
650
|
+
/** Synchronous prompt ownership per session (admission before first await). */
|
|
651
|
+
promptOwners = new Set();
|
|
648
652
|
/** At most one detached automatic-title task per durable Console session. */
|
|
649
653
|
titleInFlight = new Set();
|
|
650
654
|
/** Composer thinking selection ("auto" = no explicit override). */
|
|
@@ -665,6 +669,78 @@ export class ConsolePiRuntime {
|
|
|
665
669
|
this.loader = createOperatorChatResourceLoader();
|
|
666
670
|
this.bindings = options.bindings ?? createDefaultPiSdkBindings();
|
|
667
671
|
}
|
|
672
|
+
/** Absolute path for repo-scoped resource preferences (may be undefined in unit tests). */
|
|
673
|
+
get resourcePreferencesPath() {
|
|
674
|
+
return this.options.resourcePreferencesPath;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Load repo-scoped skill auto-invocation preferences (fail closed on malformed).
|
|
678
|
+
* Missing path / missing file → empty prefs (all skills auto-invocation default on).
|
|
679
|
+
*/
|
|
680
|
+
async loadSkillAutoInvocationPreferences() {
|
|
681
|
+
const filePath = this.options.resourcePreferencesPath;
|
|
682
|
+
if (!filePath) {
|
|
683
|
+
const empty = { schemaVersion: 1, skills: {} };
|
|
684
|
+
this.cachedSkillPrefs = empty;
|
|
685
|
+
return empty;
|
|
686
|
+
}
|
|
687
|
+
const prefs = await loadResourcePreferences(filePath);
|
|
688
|
+
this.cachedSkillPrefs = prefs;
|
|
689
|
+
return prefs;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Build resourceLoaderOptions that inject skillsOverride from stored prefs.
|
|
693
|
+
* Skills remain in inventory; only disableModelInvocation is forced for
|
|
694
|
+
* auto-invocation-off preferences. Manual /skill:name is preserved.
|
|
695
|
+
*
|
|
696
|
+
* The override re-reads preferences on EVERY invocation so session.reload()
|
|
697
|
+
* after a PATCH sees the latest resource-preferences.json (not create-time
|
|
698
|
+
* closure state).
|
|
699
|
+
*/
|
|
700
|
+
async buildResourceLoaderOptions(base = {}) {
|
|
701
|
+
const runtime = this;
|
|
702
|
+
return {
|
|
703
|
+
...base,
|
|
704
|
+
skillsOverride: (current) => {
|
|
705
|
+
// Synchronous override surface: use last-known prefs via a sync
|
|
706
|
+
// fail-closed path. Prefer async cache filled by ensurePrefs.
|
|
707
|
+
const prefs = runtime.cachedSkillPrefs ?? {
|
|
708
|
+
schemaVersion: 1,
|
|
709
|
+
skills: {},
|
|
710
|
+
};
|
|
711
|
+
return {
|
|
712
|
+
skills: applySkillAutoInvocationPreferences(current.skills, prefs),
|
|
713
|
+
diagnostics: current.diagnostics,
|
|
714
|
+
};
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
/** Last successfully loaded skill prefs (sync override + snapshot projection). */
|
|
719
|
+
cachedSkillPrefs;
|
|
720
|
+
/** Refresh cached prefs before materialization / reload so override sees latest. */
|
|
721
|
+
async refreshSkillPrefsCache() {
|
|
722
|
+
const prefs = await this.loadSkillAutoInvocationPreferences();
|
|
723
|
+
this.cachedSkillPrefs = prefs;
|
|
724
|
+
return prefs;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Resolve a skill in the current session inventory by server-issued resourceId.
|
|
728
|
+
* Returns undefined when the id is unknown (caller maps to PI_SKILL_NOT_FOUND).
|
|
729
|
+
*/
|
|
730
|
+
async findSkillByResourceId(sessionId, resourceId) {
|
|
731
|
+
const snapshot = await this.getRuntimeSnapshot(sessionId);
|
|
732
|
+
if (!snapshot)
|
|
733
|
+
return undefined;
|
|
734
|
+
const skill = snapshot.skills.find((entry) => entry.resourceId === resourceId);
|
|
735
|
+
if (!skill)
|
|
736
|
+
return undefined;
|
|
737
|
+
return {
|
|
738
|
+
resourceId: skill.resourceId,
|
|
739
|
+
name: skill.name,
|
|
740
|
+
path: skill.path,
|
|
741
|
+
autoInvocationEnabled: skill.autoInvocationEnabled,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
668
744
|
get sessionDir() {
|
|
669
745
|
return this.options.sessionDir;
|
|
670
746
|
}
|
|
@@ -820,13 +896,15 @@ export class ConsolePiRuntime {
|
|
|
820
896
|
...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
|
|
821
897
|
];
|
|
822
898
|
// Single services construction for formal materialization (+ optional
|
|
823
|
-
// default-model resolve from the same modelRuntime).
|
|
899
|
+
// default-model resolve from the same modelRuntime). Repo-scoped skill
|
|
900
|
+
// auto-invocation prefs inject skillsOverride without touching SKILL.md.
|
|
901
|
+
await this.refreshSkillPrefsCache().catch(() => undefined);
|
|
824
902
|
const { services } = await this.bindings.createServices({
|
|
825
903
|
cwd: this.options.cwd,
|
|
826
904
|
agentDir,
|
|
827
|
-
resourceLoaderOptions: {
|
|
905
|
+
resourceLoaderOptions: await this.buildResourceLoaderOptions({
|
|
828
906
|
appendSystemPrompt,
|
|
829
|
-
},
|
|
907
|
+
}),
|
|
830
908
|
});
|
|
831
909
|
if (this.disposedSessions.has(sessionId))
|
|
832
910
|
return;
|
|
@@ -1176,6 +1254,9 @@ export class ConsolePiRuntime {
|
|
|
1176
1254
|
}
|
|
1177
1255
|
this.reloadInFlight.add(sessionId);
|
|
1178
1256
|
try {
|
|
1257
|
+
// Refresh prefs cache so skillsOverride applied during reload sees
|
|
1258
|
+
// the latest resource-preferences.json written by PATCH.
|
|
1259
|
+
await this.refreshSkillPrefsCache().catch(() => undefined);
|
|
1179
1260
|
await session.reload();
|
|
1180
1261
|
}
|
|
1181
1262
|
catch (error) {
|
|
@@ -1283,9 +1364,33 @@ export class ConsolePiRuntime {
|
|
|
1283
1364
|
catch (error) {
|
|
1284
1365
|
resourceFailure("context files", error);
|
|
1285
1366
|
}
|
|
1367
|
+
// Preferences are the source of truth for UI auto-invocation state;
|
|
1368
|
+
// loader disableModelInvocation may also reflect frontmatter. Prefer
|
|
1369
|
+
// explicit preference (default on) when projecting autoInvocationEnabled.
|
|
1370
|
+
let skillPrefs = { schemaVersion: 1, skills: {} };
|
|
1371
|
+
try {
|
|
1372
|
+
skillPrefs = await this.loadSkillAutoInvocationPreferences();
|
|
1373
|
+
}
|
|
1374
|
+
catch (error) {
|
|
1375
|
+
diagnostics.push({
|
|
1376
|
+
type: "error",
|
|
1377
|
+
code: "PI_RESOURCE_PREFERENCES_MALFORMED",
|
|
1378
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1379
|
+
retryable: true,
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1286
1382
|
try {
|
|
1287
1383
|
const skillsResult = resourceLoader.getSkills?.();
|
|
1288
1384
|
for (const skill of skillsResult?.skills ?? []) {
|
|
1385
|
+
const resourceId = skillResourceId({
|
|
1386
|
+
name: skill.name,
|
|
1387
|
+
filePath: skill.filePath,
|
|
1388
|
+
});
|
|
1389
|
+
// Preference off always means autoInvocationEnabled=false even if
|
|
1390
|
+
// loader already applied disableModelInvocation. Frontmatter-only
|
|
1391
|
+
// disable (no preference) also surfaces as autoInvocationEnabled=false.
|
|
1392
|
+
const prefEnabled = isAutoInvocationEnabled(skillPrefs, resourceId);
|
|
1393
|
+
const autoInvocationEnabled = prefEnabled && skill.disableModelInvocation !== true;
|
|
1289
1394
|
skills.push({
|
|
1290
1395
|
name: skill.name,
|
|
1291
1396
|
description: skill.description ?? "",
|
|
@@ -1293,7 +1398,8 @@ export class ConsolePiRuntime {
|
|
|
1293
1398
|
...(skill.baseDir ? { baseDir: skill.baseDir } : {}),
|
|
1294
1399
|
scope: skill.sourceInfo?.scope ?? "unknown",
|
|
1295
1400
|
source: skill.sourceInfo?.source ?? "",
|
|
1296
|
-
|
|
1401
|
+
resourceId,
|
|
1402
|
+
autoInvocationEnabled,
|
|
1297
1403
|
diagnostics: [],
|
|
1298
1404
|
});
|
|
1299
1405
|
resolvedResources.push({
|
|
@@ -1620,12 +1726,13 @@ export class ConsolePiRuntime {
|
|
|
1620
1726
|
OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
|
|
1621
1727
|
...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
|
|
1622
1728
|
];
|
|
1729
|
+
await this.refreshSkillPrefsCache().catch(() => undefined);
|
|
1623
1730
|
const { services } = await this.bindings.createServices({
|
|
1624
1731
|
cwd: this.options.cwd,
|
|
1625
1732
|
agentDir,
|
|
1626
|
-
resourceLoaderOptions: {
|
|
1733
|
+
resourceLoaderOptions: await this.buildResourceLoaderOptions({
|
|
1627
1734
|
appendSystemPrompt,
|
|
1628
|
-
},
|
|
1735
|
+
}),
|
|
1629
1736
|
});
|
|
1630
1737
|
const modelRuntime = services.modelRuntime;
|
|
1631
1738
|
const resolvedModel = init.model
|
|
@@ -1669,81 +1776,142 @@ export class ConsolePiRuntime {
|
|
|
1669
1776
|
hasSession(sessionId) {
|
|
1670
1777
|
return this.sessions.has(sessionId);
|
|
1671
1778
|
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Live runtime busy facts for /state reconcile. isPromptRunning is owned by
|
|
1781
|
+
* this Console admission fence; other flags mirror the Pi session when present.
|
|
1782
|
+
*/
|
|
1783
|
+
getSessionBusyFacts(sessionId) {
|
|
1784
|
+
const session = this.sessions.get(sessionId);
|
|
1785
|
+
const isPromptRunning = this.promptOwners.has(sessionId);
|
|
1786
|
+
return {
|
|
1787
|
+
isStreaming: session?.isStreaming === true || isPromptRunning,
|
|
1788
|
+
isPromptRunning,
|
|
1789
|
+
isCompacting: session?.isCompacting === true,
|
|
1790
|
+
isBashRunning: session?.isBashRunning === true ||
|
|
1791
|
+
session?.hasPendingBashMessages === true,
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1672
1794
|
/**
|
|
1673
1795
|
* Send a prompt and stream events. Gate 3: each tool invocation is
|
|
1674
1796
|
* re-authorized by authorizeOperatorChatTool before the dispatcher runs it
|
|
1675
1797
|
* (the dispatcher is wired by the HTTP layer, see chat-session.ts).
|
|
1798
|
+
*
|
|
1799
|
+
* Prompt ownership is acquired synchronously before the first await so two
|
|
1800
|
+
* concurrent prompts on the same Session cannot both enter the SDK path.
|
|
1676
1801
|
*/
|
|
1677
1802
|
async prompt(sessionId, text, onEvent, options) {
|
|
1678
|
-
//
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
if (!session) {
|
|
1682
|
-
throw new Error(`chat session not found: ${sessionId}`);
|
|
1803
|
+
// Synchronous admission fence — before any await.
|
|
1804
|
+
if (this.promptOwners.has(sessionId)) {
|
|
1805
|
+
throw new Error("CHAT_TURN_ACTIVE");
|
|
1683
1806
|
}
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
// tools, ADR 0012).
|
|
1688
|
-
const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
|
|
1689
|
-
const activeTools = options?.mode === "requirement-interview"
|
|
1690
|
-
? filterActiveInterviewTools(requestedTools).allowed
|
|
1691
|
-
: computeOperatorChatActiveToolNames(session);
|
|
1692
|
-
session.setActiveToolsByName(activeTools);
|
|
1693
|
-
const unsub = session.subscribe((event) => {
|
|
1694
|
-
const mapped = mapSdkEvent(sessionId, event);
|
|
1695
|
-
if (mapped)
|
|
1696
|
-
onEvent(mapped);
|
|
1697
|
-
// Pi puts per-assistant-message usage on `message.usage` ({ input, output,
|
|
1698
|
-
// cacheRead, cacheWrite, totalTokens, cost }). Emit on message_end so
|
|
1699
|
-
// multi-step tool turns accumulate like pi-web SessionInfoBar; turn_end
|
|
1700
|
-
// repeats the final message and must not double-count.
|
|
1701
|
-
if (event.type === "message_end") {
|
|
1702
|
-
const usage = extractUsageSample(event);
|
|
1703
|
-
if (usage)
|
|
1704
|
-
onEvent({ type: "usage", sessionId, usage });
|
|
1705
|
-
}
|
|
1706
|
-
});
|
|
1807
|
+
this.promptOwners.add(sessionId);
|
|
1808
|
+
let unsub;
|
|
1809
|
+
let abortPromise;
|
|
1707
1810
|
const onAbort = () => {
|
|
1708
|
-
// M1 T08: Stop must abort only the current turn,
|
|
1709
|
-
// session.
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1811
|
+
// M1 T08 / AC-R3-003: Stop must abort only the current turn, NEVER
|
|
1812
|
+
// dispose the session. Await session.abort() so ownership settles
|
|
1813
|
+
// after the SDK reports idle. When abort is unavailable, fail closed
|
|
1814
|
+
// non-destructively — Session stays reusable; terminal closeout or
|
|
1815
|
+
// /state reconcile still converges ownership.
|
|
1816
|
+
const session = this.sessions.get(sessionId);
|
|
1817
|
+
if (!session)
|
|
1818
|
+
return;
|
|
1713
1819
|
try {
|
|
1714
1820
|
if (typeof session.abort === "function") {
|
|
1715
|
-
|
|
1716
|
-
}
|
|
1717
|
-
else {
|
|
1718
|
-
session.dispose();
|
|
1821
|
+
abortPromise = Promise.resolve(session.abort()).catch(() => undefined);
|
|
1719
1822
|
}
|
|
1823
|
+
// else: do not call session.dispose() — Stop never destroys Session.
|
|
1720
1824
|
}
|
|
1721
1825
|
catch {
|
|
1722
1826
|
// ignore
|
|
1723
1827
|
}
|
|
1724
1828
|
};
|
|
1725
|
-
options?.signal?.addEventListener("abort", onAbort);
|
|
1726
1829
|
try {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
//
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1830
|
+
// AC-FIX-002: if Stop already fired before readiness, never start model work.
|
|
1831
|
+
if (options?.signal?.aborted) {
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
// First message while creating waits on the same materialization promise.
|
|
1835
|
+
await this.ensureSessionReady(sessionId);
|
|
1836
|
+
// Abort may land during readiness (or between readiness and prompt).
|
|
1837
|
+
if (options?.signal?.aborted) {
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
const session = this.sessions.get(sessionId);
|
|
1841
|
+
if (!session) {
|
|
1842
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
1843
|
+
}
|
|
1844
|
+
// Gate 2 (re-pin before each turn): interview turns pass through the
|
|
1845
|
+
// existing closed interview allow/deny registry. The next ordinary turn
|
|
1846
|
+
// explicitly restores the normal Operator Chat set (baseline + extension
|
|
1847
|
+
// tools, ADR 0012).
|
|
1848
|
+
const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
|
|
1849
|
+
const activeTools = options?.mode === "requirement-interview"
|
|
1850
|
+
? filterActiveInterviewTools(requestedTools).allowed
|
|
1851
|
+
: computeOperatorChatActiveToolNames(session);
|
|
1852
|
+
session.setActiveToolsByName(activeTools);
|
|
1853
|
+
// Final pre-prompt fence: abort after tool pin must still skip session.prompt.
|
|
1854
|
+
if (options?.signal?.aborted) {
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
unsub = session.subscribe((event) => {
|
|
1858
|
+
const mapped = mapSdkEvent(sessionId, event);
|
|
1859
|
+
if (mapped)
|
|
1860
|
+
onEvent(mapped);
|
|
1861
|
+
// Pi puts per-assistant-message usage on `message.usage` ({ input, output,
|
|
1862
|
+
// cacheRead, cacheWrite, totalTokens, cost }). Emit on message_end so
|
|
1863
|
+
// multi-step tool turns accumulate like pi-web SessionInfoBar; turn_end
|
|
1864
|
+
// repeats the final message and must not double-count.
|
|
1865
|
+
if (event.type === "message_end") {
|
|
1866
|
+
const usage = extractUsageSample(event);
|
|
1867
|
+
if (usage)
|
|
1868
|
+
onEvent({ type: "usage", sessionId, usage });
|
|
1869
|
+
}
|
|
1736
1870
|
});
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1871
|
+
options?.signal?.addEventListener("abort", onAbort);
|
|
1872
|
+
if (options?.signal?.aborted) {
|
|
1873
|
+
onAbort();
|
|
1874
|
+
if (abortPromise)
|
|
1875
|
+
await abortPromise.catch(() => undefined);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
try {
|
|
1879
|
+
await session.prompt(text, options?.images?.length ? { images: options.images } : undefined);
|
|
1880
|
+
// Wait until the session reports idle / not streaming. The SDK prompt()
|
|
1881
|
+
// resolves when the agent turn completes, but post-turn continuation
|
|
1882
|
+
// (auto-compaction / follow-up) may still be in flight. We poll isIdle.
|
|
1883
|
+
// While polling we emit periodic heartbeat events so the SSE stream
|
|
1884
|
+
// keeps proxies/browsers from timing out (no other data is flowing).
|
|
1885
|
+
await waitForIdle(session, {
|
|
1886
|
+
signal: options?.signal,
|
|
1887
|
+
onHeartbeat: () => onEvent({ type: "heartbeat", sessionId }),
|
|
1888
|
+
});
|
|
1889
|
+
if (abortPromise)
|
|
1890
|
+
await abortPromise;
|
|
1891
|
+
// User abort is closed out as Turn state `aborted`; do not publish a
|
|
1892
|
+
// red ordinary error event for intentional Stop.
|
|
1893
|
+
if (!options?.signal?.aborted) {
|
|
1894
|
+
onEvent({ type: "agent_settled", sessionId });
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
catch (error) {
|
|
1898
|
+
if (abortPromise)
|
|
1899
|
+
await abortPromise.catch(() => undefined);
|
|
1900
|
+
if (options?.signal?.aborted) {
|
|
1901
|
+
// User abort: swallow ordinary error publication (AC-FIX-003).
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1905
|
+
onEvent({ type: "error", sessionId, message });
|
|
1906
|
+
throw error;
|
|
1907
|
+
}
|
|
1908
|
+
finally {
|
|
1909
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
1910
|
+
unsub?.();
|
|
1911
|
+
}
|
|
1743
1912
|
}
|
|
1744
1913
|
finally {
|
|
1745
|
-
|
|
1746
|
-
unsub();
|
|
1914
|
+
this.promptOwners.delete(sessionId);
|
|
1747
1915
|
}
|
|
1748
1916
|
}
|
|
1749
1917
|
/** Generate exactly one title in an isolated in-memory, tool-less LOW session. */
|