@tea-agent/loop-agent 0.34.3 → 0.34.5
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 +8 -3
- package/CHANGELOG.md +66 -22
- package/README.md +2 -2
- package/dist/shared/operator/capabilities.js +0 -7
- package/dist/worker/cli.js +21 -27
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +134 -4
- package/dist/worker/console/chat/pi-runtime.js +308 -63
- package/dist/worker/console/chat/resource-preferences-store.js +152 -0
- package/dist/worker/console/chat/routes.js +425 -19
- package/dist/worker/console/chat/shortcuts.js +208 -9
- package/dist/worker/console/chat/tool-preview.js +162 -5
- 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/doctor.js +1 -1
- package/dist/worker/console/index.js +1 -0
- package/dist/worker/console/open-browser.js +134 -0
- package/dist/worker/console/recovery-cta.js +1 -1
- package/dist/worker/console/server.js +5 -1
- package/dist/worker/console/static/assets/index-qpkysQYW.css +1 -0
- package/dist/worker/console/static/assets/index-y980PqtP.js +56 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/chat-markdown-security.js +38 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +1 -3
- package/dist/worker/console/static-src/operator-chat/format.js +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/worker/console/static-src/operator-chat/useChatThread.js +11 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +81 -3
- package/dist/workflows/dag/init-hybrid.js +2 -0
- package/docs/architecture/evolution.md +1 -1
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +1 -1
- package/docs/operations/local-development-environment.md +4 -2
- package/docs/templates/branch-merge-report.md +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
- package/docs/templates/evaluation/agents-map-verbose-v0.md +2 -2
- package/docs/templates/init-managed-agents.md +2 -2
- package/package.json +6 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +2 -2
- package/skills/loop-agent/references/command-reference.md +2 -3
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +0 -29
|
@@ -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) {
|
|
@@ -1198,6 +1279,83 @@ export class ConsolePiRuntime {
|
|
|
1198
1279
|
* list, and the session ResourceLoader inventories. Undefined when the
|
|
1199
1280
|
* session is not active — callers fail closed instead of fabricating data.
|
|
1200
1281
|
*/
|
|
1282
|
+
/**
|
|
1283
|
+
* Browser-safe slash command projection for UI-11.
|
|
1284
|
+
* Sources: current Session extension commands, prompt templates, skills.
|
|
1285
|
+
* Never returns prompt/skill file bodies.
|
|
1286
|
+
*/
|
|
1287
|
+
async listSlashCommands(sessionId) {
|
|
1288
|
+
const scrub = (value) => {
|
|
1289
|
+
const raw = String(value ?? "");
|
|
1290
|
+
return raw
|
|
1291
|
+
.replace(/\/Users\/[^\s]+/gi, "~")
|
|
1292
|
+
.replace(/[A-Za-z]:\\Users\\[^\s]+/gi, "~")
|
|
1293
|
+
.slice(0, 240);
|
|
1294
|
+
};
|
|
1295
|
+
try {
|
|
1296
|
+
await this.ensureSessionReady(sessionId);
|
|
1297
|
+
}
|
|
1298
|
+
catch {
|
|
1299
|
+
return [];
|
|
1300
|
+
}
|
|
1301
|
+
const session = this.sessions.get(sessionId);
|
|
1302
|
+
if (!session)
|
|
1303
|
+
return [];
|
|
1304
|
+
const out = [];
|
|
1305
|
+
const seen = new Set();
|
|
1306
|
+
const push = (command, label, description, source) => {
|
|
1307
|
+
const name = command.startsWith("/") ? command : `/${command}`;
|
|
1308
|
+
const key = name.toLowerCase();
|
|
1309
|
+
if (!key || key === "/" || seen.has(key))
|
|
1310
|
+
return;
|
|
1311
|
+
seen.add(key);
|
|
1312
|
+
out.push({
|
|
1313
|
+
command: name,
|
|
1314
|
+
label: (label || name).slice(0, 80),
|
|
1315
|
+
description: scrub(description),
|
|
1316
|
+
source,
|
|
1317
|
+
});
|
|
1318
|
+
};
|
|
1319
|
+
try {
|
|
1320
|
+
const cmds = session.extensionRunner?.getRegisteredCommands?.() ?? [];
|
|
1321
|
+
for (const cmd of cmds) {
|
|
1322
|
+
const name = cmd.invocationName || cmd.name;
|
|
1323
|
+
if (!name)
|
|
1324
|
+
continue;
|
|
1325
|
+
push(String(name), String(cmd.name || name), String(cmd.description || ""), "extension");
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
catch {
|
|
1329
|
+
// non-blocking
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
for (const tpl of session.promptTemplates ?? []) {
|
|
1333
|
+
if (!tpl?.name)
|
|
1334
|
+
continue;
|
|
1335
|
+
push(String(tpl.name), String(tpl.name), String(tpl.description || ""), "prompt");
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
// non-blocking
|
|
1340
|
+
}
|
|
1341
|
+
try {
|
|
1342
|
+
const services = this.serviceScopes.get(sessionId);
|
|
1343
|
+
const resourceLoader = services?.resourceLoader;
|
|
1344
|
+
const skills = resourceLoader?.getSkills?.()?.skills ?? [];
|
|
1345
|
+
for (const skill of skills) {
|
|
1346
|
+
if (!skill?.name)
|
|
1347
|
+
continue;
|
|
1348
|
+
const command = skill.name.startsWith("skill:")
|
|
1349
|
+
? `/${skill.name}`
|
|
1350
|
+
: `/skill:${skill.name}`;
|
|
1351
|
+
push(command, skill.name, String(skill.description || ""), "skill");
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
catch {
|
|
1355
|
+
// non-blocking
|
|
1356
|
+
}
|
|
1357
|
+
return out;
|
|
1358
|
+
}
|
|
1201
1359
|
async getRuntimeSnapshot(sessionId, options) {
|
|
1202
1360
|
try {
|
|
1203
1361
|
await this.ensureSessionReady(sessionId);
|
|
@@ -1259,7 +1417,7 @@ export class ConsolePiRuntime {
|
|
|
1259
1417
|
const skills = [];
|
|
1260
1418
|
const extensions = [];
|
|
1261
1419
|
const diagnostics = [];
|
|
1262
|
-
|
|
1420
|
+
const resolvedResources = [];
|
|
1263
1421
|
const resourceFailure = (subface, error) => {
|
|
1264
1422
|
diagnostics.push({
|
|
1265
1423
|
type: "error",
|
|
@@ -1283,9 +1441,33 @@ export class ConsolePiRuntime {
|
|
|
1283
1441
|
catch (error) {
|
|
1284
1442
|
resourceFailure("context files", error);
|
|
1285
1443
|
}
|
|
1444
|
+
// Preferences are the source of truth for UI auto-invocation state;
|
|
1445
|
+
// loader disableModelInvocation may also reflect frontmatter. Prefer
|
|
1446
|
+
// explicit preference (default on) when projecting autoInvocationEnabled.
|
|
1447
|
+
let skillPrefs = { schemaVersion: 1, skills: {} };
|
|
1448
|
+
try {
|
|
1449
|
+
skillPrefs = await this.loadSkillAutoInvocationPreferences();
|
|
1450
|
+
}
|
|
1451
|
+
catch (error) {
|
|
1452
|
+
diagnostics.push({
|
|
1453
|
+
type: "error",
|
|
1454
|
+
code: "PI_RESOURCE_PREFERENCES_MALFORMED",
|
|
1455
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1456
|
+
retryable: true,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1286
1459
|
try {
|
|
1287
1460
|
const skillsResult = resourceLoader.getSkills?.();
|
|
1288
1461
|
for (const skill of skillsResult?.skills ?? []) {
|
|
1462
|
+
const resourceId = skillResourceId({
|
|
1463
|
+
name: skill.name,
|
|
1464
|
+
filePath: skill.filePath,
|
|
1465
|
+
});
|
|
1466
|
+
// Preference off always means autoInvocationEnabled=false even if
|
|
1467
|
+
// loader already applied disableModelInvocation. Frontmatter-only
|
|
1468
|
+
// disable (no preference) also surfaces as autoInvocationEnabled=false.
|
|
1469
|
+
const prefEnabled = isAutoInvocationEnabled(skillPrefs, resourceId);
|
|
1470
|
+
const autoInvocationEnabled = prefEnabled && skill.disableModelInvocation !== true;
|
|
1289
1471
|
skills.push({
|
|
1290
1472
|
name: skill.name,
|
|
1291
1473
|
description: skill.description ?? "",
|
|
@@ -1293,7 +1475,8 @@ export class ConsolePiRuntime {
|
|
|
1293
1475
|
...(skill.baseDir ? { baseDir: skill.baseDir } : {}),
|
|
1294
1476
|
scope: skill.sourceInfo?.scope ?? "unknown",
|
|
1295
1477
|
source: skill.sourceInfo?.source ?? "",
|
|
1296
|
-
|
|
1478
|
+
resourceId,
|
|
1479
|
+
autoInvocationEnabled,
|
|
1297
1480
|
diagnostics: [],
|
|
1298
1481
|
});
|
|
1299
1482
|
resolvedResources.push({
|
|
@@ -1620,12 +1803,13 @@ export class ConsolePiRuntime {
|
|
|
1620
1803
|
OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
|
|
1621
1804
|
...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
|
|
1622
1805
|
];
|
|
1806
|
+
await this.refreshSkillPrefsCache().catch(() => undefined);
|
|
1623
1807
|
const { services } = await this.bindings.createServices({
|
|
1624
1808
|
cwd: this.options.cwd,
|
|
1625
1809
|
agentDir,
|
|
1626
|
-
resourceLoaderOptions: {
|
|
1810
|
+
resourceLoaderOptions: await this.buildResourceLoaderOptions({
|
|
1627
1811
|
appendSystemPrompt,
|
|
1628
|
-
},
|
|
1812
|
+
}),
|
|
1629
1813
|
});
|
|
1630
1814
|
const modelRuntime = services.modelRuntime;
|
|
1631
1815
|
const resolvedModel = init.model
|
|
@@ -1669,81 +1853,142 @@ export class ConsolePiRuntime {
|
|
|
1669
1853
|
hasSession(sessionId) {
|
|
1670
1854
|
return this.sessions.has(sessionId);
|
|
1671
1855
|
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Live runtime busy facts for /state reconcile. isPromptRunning is owned by
|
|
1858
|
+
* this Console admission fence; other flags mirror the Pi session when present.
|
|
1859
|
+
*/
|
|
1860
|
+
getSessionBusyFacts(sessionId) {
|
|
1861
|
+
const session = this.sessions.get(sessionId);
|
|
1862
|
+
const isPromptRunning = this.promptOwners.has(sessionId);
|
|
1863
|
+
return {
|
|
1864
|
+
isStreaming: session?.isStreaming === true || isPromptRunning,
|
|
1865
|
+
isPromptRunning,
|
|
1866
|
+
isCompacting: session?.isCompacting === true,
|
|
1867
|
+
isBashRunning: session?.isBashRunning === true ||
|
|
1868
|
+
session?.hasPendingBashMessages === true,
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1672
1871
|
/**
|
|
1673
1872
|
* Send a prompt and stream events. Gate 3: each tool invocation is
|
|
1674
1873
|
* re-authorized by authorizeOperatorChatTool before the dispatcher runs it
|
|
1675
1874
|
* (the dispatcher is wired by the HTTP layer, see chat-session.ts).
|
|
1875
|
+
*
|
|
1876
|
+
* Prompt ownership is acquired synchronously before the first await so two
|
|
1877
|
+
* concurrent prompts on the same Session cannot both enter the SDK path.
|
|
1676
1878
|
*/
|
|
1677
1879
|
async prompt(sessionId, text, onEvent, options) {
|
|
1678
|
-
//
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
if (!session) {
|
|
1682
|
-
throw new Error(`chat session not found: ${sessionId}`);
|
|
1880
|
+
// Synchronous admission fence — before any await.
|
|
1881
|
+
if (this.promptOwners.has(sessionId)) {
|
|
1882
|
+
throw new Error("CHAT_TURN_ACTIVE");
|
|
1683
1883
|
}
|
|
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
|
-
});
|
|
1884
|
+
this.promptOwners.add(sessionId);
|
|
1885
|
+
let unsub;
|
|
1886
|
+
let abortPromise;
|
|
1707
1887
|
const onAbort = () => {
|
|
1708
|
-
// M1 T08: Stop must abort only the current turn,
|
|
1709
|
-
// session.
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1888
|
+
// M1 T08 / AC-R3-003: Stop must abort only the current turn, NEVER
|
|
1889
|
+
// dispose the session. Await session.abort() so ownership settles
|
|
1890
|
+
// after the SDK reports idle. When abort is unavailable, fail closed
|
|
1891
|
+
// non-destructively — Session stays reusable; terminal closeout or
|
|
1892
|
+
// /state reconcile still converges ownership.
|
|
1893
|
+
const session = this.sessions.get(sessionId);
|
|
1894
|
+
if (!session)
|
|
1895
|
+
return;
|
|
1713
1896
|
try {
|
|
1714
1897
|
if (typeof session.abort === "function") {
|
|
1715
|
-
|
|
1716
|
-
}
|
|
1717
|
-
else {
|
|
1718
|
-
session.dispose();
|
|
1898
|
+
abortPromise = Promise.resolve(session.abort()).catch(() => undefined);
|
|
1719
1899
|
}
|
|
1900
|
+
// else: do not call session.dispose() — Stop never destroys Session.
|
|
1720
1901
|
}
|
|
1721
1902
|
catch {
|
|
1722
1903
|
// ignore
|
|
1723
1904
|
}
|
|
1724
1905
|
};
|
|
1725
|
-
options?.signal?.addEventListener("abort", onAbort);
|
|
1726
1906
|
try {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
//
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1907
|
+
// AC-FIX-002: if Stop already fired before readiness, never start model work.
|
|
1908
|
+
if (options?.signal?.aborted) {
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
// First message while creating waits on the same materialization promise.
|
|
1912
|
+
await this.ensureSessionReady(sessionId);
|
|
1913
|
+
// Abort may land during readiness (or between readiness and prompt).
|
|
1914
|
+
if (options?.signal?.aborted) {
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const session = this.sessions.get(sessionId);
|
|
1918
|
+
if (!session) {
|
|
1919
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
1920
|
+
}
|
|
1921
|
+
// Gate 2 (re-pin before each turn): interview turns pass through the
|
|
1922
|
+
// existing closed interview allow/deny registry. The next ordinary turn
|
|
1923
|
+
// explicitly restores the normal Operator Chat set (baseline + extension
|
|
1924
|
+
// tools, ADR 0012).
|
|
1925
|
+
const requestedTools = OPERATOR_CHAT_ACTIVE_TOOL_NAMES();
|
|
1926
|
+
const activeTools = options?.mode === "requirement-interview"
|
|
1927
|
+
? filterActiveInterviewTools(requestedTools).allowed
|
|
1928
|
+
: computeOperatorChatActiveToolNames(session);
|
|
1929
|
+
session.setActiveToolsByName(activeTools);
|
|
1930
|
+
// Final pre-prompt fence: abort after tool pin must still skip session.prompt.
|
|
1931
|
+
if (options?.signal?.aborted) {
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
unsub = session.subscribe((event) => {
|
|
1935
|
+
const mapped = mapSdkEvent(sessionId, event);
|
|
1936
|
+
if (mapped)
|
|
1937
|
+
onEvent(mapped);
|
|
1938
|
+
// Pi puts per-assistant-message usage on `message.usage` ({ input, output,
|
|
1939
|
+
// cacheRead, cacheWrite, totalTokens, cost }). Emit on message_end so
|
|
1940
|
+
// multi-step tool turns accumulate like pi-web SessionInfoBar; turn_end
|
|
1941
|
+
// repeats the final message and must not double-count.
|
|
1942
|
+
if (event.type === "message_end") {
|
|
1943
|
+
const usage = extractUsageSample(event);
|
|
1944
|
+
if (usage)
|
|
1945
|
+
onEvent({ type: "usage", sessionId, usage });
|
|
1946
|
+
}
|
|
1736
1947
|
});
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1948
|
+
options?.signal?.addEventListener("abort", onAbort);
|
|
1949
|
+
if (options?.signal?.aborted) {
|
|
1950
|
+
onAbort();
|
|
1951
|
+
if (abortPromise)
|
|
1952
|
+
await abortPromise.catch(() => undefined);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
try {
|
|
1956
|
+
await session.prompt(text, options?.images?.length ? { images: options.images } : undefined);
|
|
1957
|
+
// Wait until the session reports idle / not streaming. The SDK prompt()
|
|
1958
|
+
// resolves when the agent turn completes, but post-turn continuation
|
|
1959
|
+
// (auto-compaction / follow-up) may still be in flight. We poll isIdle.
|
|
1960
|
+
// While polling we emit periodic heartbeat events so the SSE stream
|
|
1961
|
+
// keeps proxies/browsers from timing out (no other data is flowing).
|
|
1962
|
+
await waitForIdle(session, {
|
|
1963
|
+
signal: options?.signal,
|
|
1964
|
+
onHeartbeat: () => onEvent({ type: "heartbeat", sessionId }),
|
|
1965
|
+
});
|
|
1966
|
+
if (abortPromise)
|
|
1967
|
+
await abortPromise;
|
|
1968
|
+
// User abort is closed out as Turn state `aborted`; do not publish a
|
|
1969
|
+
// red ordinary error event for intentional Stop.
|
|
1970
|
+
if (!options?.signal?.aborted) {
|
|
1971
|
+
onEvent({ type: "agent_settled", sessionId });
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
catch (error) {
|
|
1975
|
+
if (abortPromise)
|
|
1976
|
+
await abortPromise.catch(() => undefined);
|
|
1977
|
+
if (options?.signal?.aborted) {
|
|
1978
|
+
// User abort: swallow ordinary error publication (AC-FIX-003).
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1982
|
+
onEvent({ type: "error", sessionId, message });
|
|
1983
|
+
throw error;
|
|
1984
|
+
}
|
|
1985
|
+
finally {
|
|
1986
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
1987
|
+
unsub?.();
|
|
1988
|
+
}
|
|
1743
1989
|
}
|
|
1744
1990
|
finally {
|
|
1745
|
-
|
|
1746
|
-
unsub();
|
|
1991
|
+
this.promptOwners.delete(sessionId);
|
|
1747
1992
|
}
|
|
1748
1993
|
}
|
|
1749
1994
|
/** Generate exactly one title in an isolated in-memory, tool-less LOW session. */
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — repo-scoped resource auto-invocation preferences.
|
|
3
|
+
*
|
|
4
|
+
* Persists under Console app-data `resource-preferences.json` (per repo
|
|
5
|
+
* fingerprint). Preferences never touch SKILL.md, Pi settings, or tracked repo
|
|
6
|
+
* files. Malformed on-disk data fails closed.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
10
|
+
import { readJsonIfExists, writeSecureJson, } from "../app-data.js";
|
|
11
|
+
export const RESOURCE_PREFERENCES_SCHEMA_VERSION = 1;
|
|
12
|
+
export class ResourcePreferencesError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "ResourcePreferencesError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Stable server-issued resource id for a discovered skill (path + name). */
|
|
21
|
+
export function skillResourceId(skill) {
|
|
22
|
+
return createHash("sha256")
|
|
23
|
+
.update(`skill\0${skill.name}\0${skill.filePath}`, "utf8")
|
|
24
|
+
.digest("hex")
|
|
25
|
+
.slice(0, 32);
|
|
26
|
+
}
|
|
27
|
+
function emptyPreferences() {
|
|
28
|
+
return {
|
|
29
|
+
schemaVersion: RESOURCE_PREFERENCES_SCHEMA_VERSION,
|
|
30
|
+
skills: {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse and validate on-disk preferences. Malformed content fails closed with
|
|
38
|
+
* a structured error (never silently treated as empty-success).
|
|
39
|
+
*/
|
|
40
|
+
export function parseResourcePreferences(raw) {
|
|
41
|
+
if (raw === undefined)
|
|
42
|
+
return emptyPreferences();
|
|
43
|
+
if (!isPlainObject(raw)) {
|
|
44
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", "resource-preferences.json must be a JSON object");
|
|
45
|
+
}
|
|
46
|
+
if (raw.schemaVersion !== RESOURCE_PREFERENCES_SCHEMA_VERSION) {
|
|
47
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", `unsupported resource-preferences schemaVersion: ${String(raw.schemaVersion)}`);
|
|
48
|
+
}
|
|
49
|
+
if (!isPlainObject(raw.skills)) {
|
|
50
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", "resource-preferences.skills must be an object");
|
|
51
|
+
}
|
|
52
|
+
const skills = {};
|
|
53
|
+
for (const [resourceId, entry] of Object.entries(raw.skills)) {
|
|
54
|
+
if (typeof resourceId !== "string" || !resourceId.trim()) {
|
|
55
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", "resource-preferences skill key must be a non-empty string");
|
|
56
|
+
}
|
|
57
|
+
if (!isPlainObject(entry)) {
|
|
58
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", `resource-preferences entry for ${resourceId} must be an object`);
|
|
59
|
+
}
|
|
60
|
+
if (typeof entry.autoInvocationEnabled !== "boolean") {
|
|
61
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", `resource-preferences entry for ${resourceId} missing boolean autoInvocationEnabled`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof entry.updatedAt !== "string" || !entry.updatedAt.trim()) {
|
|
64
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", `resource-preferences entry for ${resourceId} missing updatedAt`);
|
|
65
|
+
}
|
|
66
|
+
skills[resourceId] = {
|
|
67
|
+
autoInvocationEnabled: entry.autoInvocationEnabled,
|
|
68
|
+
updatedAt: entry.updatedAt,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
schemaVersion: RESOURCE_PREFERENCES_SCHEMA_VERSION,
|
|
73
|
+
skills,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export async function loadResourcePreferences(filePath) {
|
|
77
|
+
if (!filePath)
|
|
78
|
+
return emptyPreferences();
|
|
79
|
+
if (existsSync(filePath)) {
|
|
80
|
+
const st = lstatSync(filePath);
|
|
81
|
+
if (st.isSymbolicLink() || !st.isFile()) {
|
|
82
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", `refusing to read non-regular resource-preferences file: ${filePath}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = await readJsonIfExists(filePath);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_MALFORMED", error instanceof Error ? error.message : String(error));
|
|
91
|
+
}
|
|
92
|
+
return parseResourcePreferences(raw);
|
|
93
|
+
}
|
|
94
|
+
/** resourceIds whose auto-invocation is explicitly disabled (default = enabled). */
|
|
95
|
+
export function disabledAutoInvocationIds(prefs) {
|
|
96
|
+
const disabled = new Set();
|
|
97
|
+
for (const [id, entry] of Object.entries(prefs.skills)) {
|
|
98
|
+
if (entry.autoInvocationEnabled === false)
|
|
99
|
+
disabled.add(id);
|
|
100
|
+
}
|
|
101
|
+
return disabled;
|
|
102
|
+
}
|
|
103
|
+
export function isAutoInvocationEnabled(prefs, resourceId) {
|
|
104
|
+
const entry = prefs.skills[resourceId];
|
|
105
|
+
if (!entry)
|
|
106
|
+
return true;
|
|
107
|
+
return entry.autoInvocationEnabled !== false;
|
|
108
|
+
}
|
|
109
|
+
export async function setSkillAutoInvocationPreference(options) {
|
|
110
|
+
const current = await loadResourcePreferences(options.filePath);
|
|
111
|
+
const next = {
|
|
112
|
+
schemaVersion: RESOURCE_PREFERENCES_SCHEMA_VERSION,
|
|
113
|
+
skills: {
|
|
114
|
+
...current.skills,
|
|
115
|
+
[options.resourceId]: {
|
|
116
|
+
autoInvocationEnabled: options.autoInvocationEnabled,
|
|
117
|
+
updatedAt: options.now ?? new Date().toISOString(),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
// Default-on entries may be pruned to keep the file sparse when re-enabled.
|
|
122
|
+
if (options.autoInvocationEnabled === true) {
|
|
123
|
+
// Keep explicit true only when previously present was false; either way
|
|
124
|
+
// store the affirmative so UI/history can show last toggle time.
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await writeSecureJson(options.filePath, next);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new ResourcePreferencesError("PI_RESOURCE_PREFERENCES_WRITE_FAILED", error instanceof Error ? error.message : String(error));
|
|
131
|
+
}
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Apply stored auto-invocation preferences onto a discovered skill list.
|
|
136
|
+
* Skills remain in inventory (manual /skill:name stays available); only
|
|
137
|
+
* `disableModelInvocation` is forced true when the preference is off.
|
|
138
|
+
* Existing frontmatter disableModelInvocation is never cleared.
|
|
139
|
+
*/
|
|
140
|
+
export function applySkillAutoInvocationPreferences(skills, prefs) {
|
|
141
|
+
const disabled = disabledAutoInvocationIds(prefs);
|
|
142
|
+
if (disabled.size === 0)
|
|
143
|
+
return skills;
|
|
144
|
+
return skills.map((skill) => {
|
|
145
|
+
const id = skillResourceId(skill);
|
|
146
|
+
if (!disabled.has(id))
|
|
147
|
+
return skill;
|
|
148
|
+
if (skill.disableModelInvocation === true)
|
|
149
|
+
return skill;
|
|
150
|
+
return { ...skill, disableModelInvocation: true };
|
|
151
|
+
});
|
|
152
|
+
}
|