@tea-agent/loop-agent 0.33.6 → 0.34.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/CHANGELOG.md +21 -0
- package/dist/worker/console/chat/model-resolver.js +17 -0
- package/dist/worker/console/chat/pi-runtime.js +397 -132
- package/dist/worker/console/chat/routes.js +185 -25
- package/dist/worker/console/chat/session-store.js +39 -0
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
- package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
- package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
- package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +109 -22
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +6 -1
- package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
- package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/local-jacoco-coverage/SKILL.md +281 -0
- package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
- package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
- package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
- package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
- package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
- package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
|
@@ -33,7 +33,7 @@ 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
35
|
import { buildModelCallableToolSchemas } from "./tool-adapter.js";
|
|
36
|
-
import { resolveDefaultChatModel, } from "./model-resolver.js";
|
|
36
|
+
import { resolveDefaultChatModel, resolveLowChatModel, } from "./model-resolver.js";
|
|
37
37
|
import { filterActiveInterviewTools } from "../interview/tools.js";
|
|
38
38
|
import { RUNTIME_CONTEXT_TEXT_MAX, redactRuntimeText, } from "./runtime-context.js";
|
|
39
39
|
/**
|
|
@@ -602,6 +602,33 @@ function readSessionManagerId(manager) {
|
|
|
602
602
|
}
|
|
603
603
|
return undefined;
|
|
604
604
|
}
|
|
605
|
+
function readSessionManagerFile(manager) {
|
|
606
|
+
if (!manager || typeof manager !== "object")
|
|
607
|
+
return undefined;
|
|
608
|
+
const record = manager;
|
|
609
|
+
if (typeof record.getSessionFile === "function") {
|
|
610
|
+
try {
|
|
611
|
+
const value = record.getSessionFile();
|
|
612
|
+
if (typeof value === "string" && value.trim())
|
|
613
|
+
return value;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
// ignore
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (typeof record.sessionFile === "string" && record.sessionFile.trim()) {
|
|
620
|
+
return record.sessionFile;
|
|
621
|
+
}
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
/** Structured materialization failure for dependent Chat endpoints. */
|
|
625
|
+
export class PiSessionInitFailedError extends Error {
|
|
626
|
+
code = "PI_SESSION_INIT_FAILED";
|
|
627
|
+
constructor(message) {
|
|
628
|
+
super(message);
|
|
629
|
+
this.name = "PiSessionInitFailedError";
|
|
630
|
+
}
|
|
631
|
+
}
|
|
605
632
|
/**
|
|
606
633
|
* Console Pi runtime — holds Chat sessions and drives prompt() with three-gate
|
|
607
634
|
* tool enforcement. Session-per-sessionId; cross-request reuse via open().
|
|
@@ -618,9 +645,20 @@ export class ConsolePiRuntime {
|
|
|
618
645
|
revisions = new Map();
|
|
619
646
|
/** RF-01: per-session reload in-flight lock (concurrent second call → PI_SESSION_BUSY). */
|
|
620
647
|
reloadInFlight = new Set();
|
|
648
|
+
/** At most one detached automatic-title task per durable Console session. */
|
|
649
|
+
titleInFlight = new Set();
|
|
621
650
|
/** Composer thinking selection ("auto" = no explicit override). */
|
|
622
651
|
thinkingSelections = new Map();
|
|
623
652
|
mainlineLeaves = new Map();
|
|
653
|
+
/** Per-sessionId single-flight materialization (create fast-path / readiness fence). */
|
|
654
|
+
sessionInit = new Map();
|
|
655
|
+
/** Resolved model binding after materialization (create may return before this is known). */
|
|
656
|
+
sessionModels = new Map();
|
|
657
|
+
/** SessionIds disposed while materialization was still in flight. */
|
|
658
|
+
disposedSessions = new Set();
|
|
659
|
+
/** Session-less tool definition caches (no AgentSession state). */
|
|
660
|
+
operatorCustomToolsPromise;
|
|
661
|
+
exploreCustomToolsPromise;
|
|
624
662
|
bindings;
|
|
625
663
|
constructor(options) {
|
|
626
664
|
this.options = options;
|
|
@@ -655,18 +693,20 @@ export class ConsolePiRuntime {
|
|
|
655
693
|
* Resolve the Chat default model descriptor from harness.json
|
|
656
694
|
* executors.pi.MED + the SDK available-model list. Returns undefined when
|
|
657
695
|
* harness is unset or no provider surfaces the model. No hardcoded fallback.
|
|
696
|
+
*
|
|
697
|
+
* Prefer reusing an already-built modelRuntime (same services object as
|
|
698
|
+
* formal materialization) so create does not construct throwaway services.
|
|
658
699
|
*/
|
|
659
|
-
async resolveDefaultModel() {
|
|
700
|
+
async resolveDefaultModel(modelRuntime) {
|
|
660
701
|
let available;
|
|
661
702
|
try {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
});
|
|
703
|
+
const runtime = modelRuntime ??
|
|
704
|
+
(await this.bindings.createServices({
|
|
705
|
+
cwd: this.options.cwd,
|
|
706
|
+
agentDir: await safeGetAgentDir(this.bindings),
|
|
707
|
+
})).services.modelRuntime;
|
|
668
708
|
available = this.bindings.listAvailableModels({
|
|
669
|
-
modelRuntime:
|
|
709
|
+
modelRuntime: runtime,
|
|
670
710
|
});
|
|
671
711
|
}
|
|
672
712
|
catch {
|
|
@@ -680,29 +720,107 @@ export class ConsolePiRuntime {
|
|
|
680
720
|
modelId: descriptor.modelId,
|
|
681
721
|
};
|
|
682
722
|
}
|
|
683
|
-
/**
|
|
723
|
+
/**
|
|
724
|
+
* Fast create: durable SessionManager/JSONL shell first, then single-flight
|
|
725
|
+
* AgentSession materialization in the background. 201 may report
|
|
726
|
+
* `initializing: true`; dependent routes wait on the same promise.
|
|
727
|
+
*/
|
|
684
728
|
async createSession(init) {
|
|
685
729
|
const sessionId = `operator-chat-${randomBytes(12).toString("hex")}`;
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
730
|
+
this.disposedSessions.delete(sessionId);
|
|
731
|
+
const sessionManager = await this.bindings.createSessionManager({
|
|
732
|
+
cwd: this.options.cwd,
|
|
733
|
+
sessionDir: this.options.sessionDir,
|
|
734
|
+
sessionId,
|
|
735
|
+
});
|
|
736
|
+
const sessionFile = readSessionManagerFile(sessionManager);
|
|
737
|
+
if (!sessionFile) {
|
|
738
|
+
throw new Error(`chat session shell missing sessionFile for ${sessionId}`);
|
|
695
739
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
//
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
740
|
+
this.sessionManagers.set(sessionId, sessionManager);
|
|
741
|
+
this.thinkingSelections.set(sessionId, "auto");
|
|
742
|
+
// Detached single-flight materialization; dependent routes await the same promise.
|
|
743
|
+
void this.startMaterialization(sessionId, {
|
|
744
|
+
sessionManager,
|
|
745
|
+
model: init?.model,
|
|
746
|
+
systemPromptSuffix: init?.systemPromptSuffix,
|
|
747
|
+
// Create path must resolve default model from the SAME services object
|
|
748
|
+
// used for formal materialization (no throwaway createServices).
|
|
749
|
+
resolveDefaultModel: !init?.model,
|
|
750
|
+
});
|
|
751
|
+
return {
|
|
752
|
+
sessionId,
|
|
753
|
+
sessionFile,
|
|
754
|
+
createdAt: new Date().toISOString(),
|
|
755
|
+
model: init?.model,
|
|
756
|
+
activeTools: OPERATOR_CHAT_ACTIVE_TOOL_NAMES(),
|
|
757
|
+
initializing: true,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Wait for single-flight materialization. Ready sessions resolve immediately;
|
|
762
|
+
* failed materialization throws PiSessionInitFailedError.
|
|
763
|
+
*/
|
|
764
|
+
async ensureSessionReady(sessionId) {
|
|
765
|
+
const state = this.sessionInit.get(sessionId);
|
|
766
|
+
if (!state) {
|
|
767
|
+
if (this.sessions.has(sessionId))
|
|
768
|
+
return;
|
|
769
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
770
|
+
}
|
|
771
|
+
if (state.status === "ready")
|
|
772
|
+
return;
|
|
773
|
+
if (state.status === "failed")
|
|
774
|
+
throw state.error;
|
|
775
|
+
await state.promise;
|
|
776
|
+
const next = this.sessionInit.get(sessionId);
|
|
777
|
+
if (next?.status === "failed")
|
|
778
|
+
throw next.error;
|
|
779
|
+
if (this.disposedSessions.has(sessionId) || !this.sessions.has(sessionId)) {
|
|
780
|
+
throw new Error(`chat session not found: ${sessionId}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
startMaterialization(sessionId, init) {
|
|
784
|
+
const existing = this.sessionInit.get(sessionId);
|
|
785
|
+
if (existing?.status === "initializing")
|
|
786
|
+
return existing.promise;
|
|
787
|
+
if (existing?.status === "ready")
|
|
788
|
+
return Promise.resolve();
|
|
789
|
+
if (existing?.status === "failed")
|
|
790
|
+
return Promise.reject(existing.error);
|
|
791
|
+
const promise = this.materializeSession(sessionId, init)
|
|
792
|
+
.then(() => {
|
|
793
|
+
if (this.disposedSessions.has(sessionId)) {
|
|
794
|
+
// Late materialization after delete: never register ready state.
|
|
795
|
+
this.sessionInit.delete(sessionId);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
this.sessionInit.set(sessionId, { status: "ready" });
|
|
799
|
+
})
|
|
800
|
+
.catch((error) => {
|
|
801
|
+
const failed = new PiSessionInitFailedError(error instanceof Error ? error.message : String(error));
|
|
802
|
+
if (!this.disposedSessions.has(sessionId)) {
|
|
803
|
+
this.sessionInit.set(sessionId, { status: "failed", error: failed });
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
this.sessionInit.delete(sessionId);
|
|
807
|
+
}
|
|
808
|
+
// Observe rejection so detached create path has no unhandled rejection.
|
|
809
|
+
process.stderr.write(`[console] chat session init failed (${sessionId}): ${failed.message}\n`);
|
|
810
|
+
});
|
|
811
|
+
this.sessionInit.set(sessionId, { status: "initializing", promise });
|
|
812
|
+
return promise;
|
|
813
|
+
}
|
|
814
|
+
async materializeSession(sessionId, init) {
|
|
815
|
+
if (this.disposedSessions.has(sessionId))
|
|
816
|
+
return;
|
|
817
|
+
const agentDir = await safeGetAgentDir(this.bindings);
|
|
702
818
|
const appendSystemPrompt = [
|
|
703
819
|
OPERATOR_CHAT_SYSTEM_PROMPT_BASE,
|
|
704
|
-
...(init
|
|
820
|
+
...(init.systemPromptSuffix ? [init.systemPromptSuffix] : []),
|
|
705
821
|
];
|
|
822
|
+
// Single services construction for formal materialization (+ optional
|
|
823
|
+
// default-model resolve from the same modelRuntime).
|
|
706
824
|
const { services } = await this.bindings.createServices({
|
|
707
825
|
cwd: this.options.cwd,
|
|
708
826
|
agentDir,
|
|
@@ -710,10 +828,13 @@ export class ConsolePiRuntime {
|
|
|
710
828
|
appendSystemPrompt,
|
|
711
829
|
},
|
|
712
830
|
});
|
|
831
|
+
if (this.disposedSessions.has(sessionId))
|
|
832
|
+
return;
|
|
713
833
|
const modelRuntime = services.modelRuntime;
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
834
|
+
let model = init.model;
|
|
835
|
+
if (!model && init.resolveDefaultModel) {
|
|
836
|
+
model = await this.resolveDefaultModel(modelRuntime);
|
|
837
|
+
}
|
|
717
838
|
const resolvedModel = model
|
|
718
839
|
? (this.bindings.resolveModel({
|
|
719
840
|
modelRuntime,
|
|
@@ -721,61 +842,13 @@ export class ConsolePiRuntime {
|
|
|
721
842
|
modelId: model.modelId,
|
|
722
843
|
}) ?? undefined)
|
|
723
844
|
: undefined;
|
|
724
|
-
const sessionManager = await this.bindings.createSessionManager({
|
|
725
|
-
cwd: this.options.cwd,
|
|
726
|
-
sessionDir: this.options.sessionDir,
|
|
727
|
-
sessionId,
|
|
728
|
-
});
|
|
729
|
-
// Gate 1: pin the tool set at session create. assertNoWriteTool guards
|
|
730
|
-
// that no file-WRITING coding tool slipped into the allowed list (bash
|
|
731
|
-
// is intentionally allowed since the 2026-07-25 widening).
|
|
732
845
|
assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
if (this.options.actionContext) {
|
|
737
|
-
try {
|
|
738
|
-
operatorCustomTools = await buildCustomOperatorTools({
|
|
739
|
-
actionContext: this.options.actionContext,
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
catch (error) {
|
|
743
|
-
// If custom-tool build fails (e.g. typebox unavailable), fall back to
|
|
744
|
-
// a tool-less session — Chat still works as text-only, and the
|
|
745
|
-
// failure is surfaced via doctor / readiness diagnostics.
|
|
746
|
-
process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
// M0-A: also register the safe explore tools (safe-read / safe-grep /
|
|
750
|
-
// git-status / git-diff) so the model can probe the repo without bash.
|
|
751
|
-
let exploreCustomTools = [];
|
|
752
|
-
try {
|
|
753
|
-
exploreCustomTools = await buildExploreCustomTools({
|
|
754
|
-
repoRoot: this.options.cwd,
|
|
755
|
-
});
|
|
756
|
-
}
|
|
757
|
-
catch (error) {
|
|
758
|
-
process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
759
|
-
}
|
|
760
|
-
const customTools = [...operatorCustomTools, ...exploreCustomTools];
|
|
761
|
-
// Tool surface (roadmap M0):
|
|
762
|
-
// - ALL model-callable operator actions are registered as customTools.
|
|
763
|
-
// - Safe explore tools (safe-read / safe-grep / git-status / git-diff)
|
|
764
|
-
// are also registered as customTools (bash is GONE — M0-A removes
|
|
765
|
-
// the write-via-redirect escape).
|
|
766
|
-
// - Built-in find/ls are registered by passing `tools:` (Gate 1). The
|
|
767
|
-
// default builtin read/bash/edit/write are EXCLUDED: read/bash/grep
|
|
768
|
-
// are replaced by the safe custom versions above; edit/write stay
|
|
769
|
-
// excluded (file-writing never activated).
|
|
770
|
-
// by passing `tools: OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS` (Gate 1).
|
|
771
|
-
// This is REQUIRED: the SDK's default builtin set is only
|
|
772
|
-
// `read, bash, edit, write` (grep/find/ls are NOT default builtins —
|
|
773
|
-
// see SDK CreateAgentSessionOptions.tools docs). Without `tools`, the
|
|
846
|
+
const customTools = await this.loadCustomTools();
|
|
847
|
+
if (this.disposedSessions.has(sessionId))
|
|
848
|
+
return;
|
|
774
849
|
const { session } = await this.bindings.createSessionFromServices({
|
|
775
850
|
services,
|
|
776
|
-
sessionManager,
|
|
777
|
-
// Gate 1 (ADR 0011): full Pi builtins registered; only non-Pi write
|
|
778
|
-
// channels excluded (apply_patch / full-tools / shell / coding-chat).
|
|
851
|
+
sessionManager: init.sessionManager,
|
|
779
852
|
tools: [...OPERATOR_CHAT_BUILTIN_EXPLORE_TOOLS],
|
|
780
853
|
excludeTools: [
|
|
781
854
|
"apply_patch",
|
|
@@ -786,30 +859,82 @@ export class ConsolePiRuntime {
|
|
|
786
859
|
"coding_chat",
|
|
787
860
|
"shell",
|
|
788
861
|
],
|
|
789
|
-
...(customTools
|
|
862
|
+
...(customTools.length > 0 ? { customTools } : {}),
|
|
790
863
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
791
864
|
});
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
865
|
+
if (this.disposedSessions.has(sessionId)) {
|
|
866
|
+
try {
|
|
867
|
+
session.dispose();
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
// ignore late dispose cleanup
|
|
871
|
+
}
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
796
874
|
session.setActiveToolsByName(computeOperatorChatActiveToolNames(session));
|
|
875
|
+
// Register then re-check dispose race: a concurrent dispose must win.
|
|
797
876
|
this.sessions.set(sessionId, session);
|
|
798
|
-
this.sessionManagers.set(sessionId, sessionManager);
|
|
877
|
+
this.sessionManagers.set(sessionId, init.sessionManager);
|
|
799
878
|
this.modelRuntimes.set(sessionId, modelRuntime);
|
|
879
|
+
this.sessionModels.set(sessionId, model);
|
|
800
880
|
this.serviceScopes.set(sessionId, {
|
|
801
881
|
resourceLoader: services.resourceLoader,
|
|
802
882
|
settingsManager: services.settingsManager,
|
|
803
883
|
});
|
|
804
884
|
this.revisions.set(sessionId, 1);
|
|
805
|
-
this.thinkingSelections.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
885
|
+
if (!this.thinkingSelections.has(sessionId)) {
|
|
886
|
+
this.thinkingSelections.set(sessionId, "auto");
|
|
887
|
+
}
|
|
888
|
+
if (this.disposedSessions.has(sessionId)) {
|
|
889
|
+
try {
|
|
890
|
+
session.dispose();
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
// ignore
|
|
894
|
+
}
|
|
895
|
+
this.sessions.delete(sessionId);
|
|
896
|
+
this.sessionManagers.delete(sessionId);
|
|
897
|
+
this.modelRuntimes.delete(sessionId);
|
|
898
|
+
this.sessionModels.delete(sessionId);
|
|
899
|
+
this.serviceScopes.delete(sessionId);
|
|
900
|
+
this.revisions.delete(sessionId);
|
|
901
|
+
this.thinkingSelections.delete(sessionId);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/** Cached session-less custom tool definitions (operator + explore). */
|
|
905
|
+
async loadCustomTools() {
|
|
906
|
+
const [operatorCustomTools, exploreCustomTools] = await Promise.all([
|
|
907
|
+
this.loadOperatorCustomTools(),
|
|
908
|
+
this.loadExploreCustomTools(),
|
|
909
|
+
]);
|
|
910
|
+
return [...operatorCustomTools, ...exploreCustomTools];
|
|
911
|
+
}
|
|
912
|
+
loadOperatorCustomTools() {
|
|
913
|
+
if (!this.options.actionContext)
|
|
914
|
+
return Promise.resolve([]);
|
|
915
|
+
if (!this.operatorCustomToolsPromise) {
|
|
916
|
+
this.operatorCustomToolsPromise = buildCustomOperatorTools({
|
|
917
|
+
actionContext: this.options.actionContext,
|
|
918
|
+
}).catch((error) => {
|
|
919
|
+
process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
920
|
+
// Allow a later create/reopen to retry after a transient failure.
|
|
921
|
+
this.operatorCustomToolsPromise = undefined;
|
|
922
|
+
return [];
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
return this.operatorCustomToolsPromise;
|
|
926
|
+
}
|
|
927
|
+
loadExploreCustomTools() {
|
|
928
|
+
if (!this.exploreCustomToolsPromise) {
|
|
929
|
+
this.exploreCustomToolsPromise = buildExploreCustomTools({
|
|
930
|
+
repoRoot: this.options.cwd,
|
|
931
|
+
}).catch((error) => {
|
|
932
|
+
process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
933
|
+
this.exploreCustomToolsPromise = undefined;
|
|
934
|
+
return [];
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
return this.exploreCustomToolsPromise;
|
|
813
938
|
}
|
|
814
939
|
async forkSession(init) {
|
|
815
940
|
if (!this.bindings.forkSessionManager)
|
|
@@ -829,9 +954,11 @@ export class ConsolePiRuntime {
|
|
|
829
954
|
this.sessions.set(sessionId, built.session);
|
|
830
955
|
this.sessionManagers.set(sessionId, sessionManager);
|
|
831
956
|
this.modelRuntimes.set(sessionId, built.modelRuntime);
|
|
957
|
+
this.sessionModels.set(sessionId, init.model);
|
|
832
958
|
this.serviceScopes.set(sessionId, built.services);
|
|
833
959
|
this.revisions.set(sessionId, 1);
|
|
834
960
|
this.thinkingSelections.set(sessionId, "auto");
|
|
961
|
+
this.sessionInit.set(sessionId, { status: "ready" });
|
|
835
962
|
return {
|
|
836
963
|
sessionId,
|
|
837
964
|
sessionFile: built.session.sessionFile,
|
|
@@ -876,12 +1003,14 @@ export class ConsolePiRuntime {
|
|
|
876
1003
|
};
|
|
877
1004
|
}
|
|
878
1005
|
async listModels(sessionId) {
|
|
1006
|
+
await this.ensureSessionReady(sessionId);
|
|
879
1007
|
const modelRuntime = this.modelRuntimes.get(sessionId);
|
|
880
1008
|
if (!this.sessions.has(sessionId) || !modelRuntime)
|
|
881
1009
|
throw new Error(`chat session not active: ${sessionId}`);
|
|
882
1010
|
return this.bindings.listAvailableModels({ modelRuntime });
|
|
883
1011
|
}
|
|
884
1012
|
async applyModel(sessionId, model) {
|
|
1013
|
+
await this.ensureSessionReady(sessionId);
|
|
885
1014
|
const session = this.sessions.get(sessionId);
|
|
886
1015
|
const modelRuntime = this.modelRuntimes.get(sessionId);
|
|
887
1016
|
if (!session || !modelRuntime)
|
|
@@ -908,6 +1037,12 @@ export class ConsolePiRuntime {
|
|
|
908
1037
|
* level calls `session.setThinkingLevel`. Returns the effective value.
|
|
909
1038
|
*/
|
|
910
1039
|
applyThinkingLevel(sessionId, level) {
|
|
1040
|
+
const state = this.sessionInit.get(sessionId);
|
|
1041
|
+
if (state?.status === "failed")
|
|
1042
|
+
throw state.error;
|
|
1043
|
+
if (state?.status === "initializing") {
|
|
1044
|
+
throw new Error(`chat session not active: ${sessionId}`);
|
|
1045
|
+
}
|
|
911
1046
|
const session = this.sessions.get(sessionId);
|
|
912
1047
|
if (!session)
|
|
913
1048
|
throw new Error(`chat session not active: ${sessionId}`);
|
|
@@ -927,8 +1062,9 @@ export class ConsolePiRuntime {
|
|
|
927
1062
|
const session = this.sessions.get(sessionId);
|
|
928
1063
|
if (!session)
|
|
929
1064
|
return { activeTools: [] };
|
|
930
|
-
const
|
|
931
|
-
const
|
|
1065
|
+
const stored = this.sessionModels.get(sessionId);
|
|
1066
|
+
const provider = session.model?.provider ?? stored?.provider;
|
|
1067
|
+
const modelId = session.model?.id ?? session.model?.modelId ?? stored?.modelId;
|
|
932
1068
|
return {
|
|
933
1069
|
...(provider && modelId ? { model: { provider, modelId } } : {}),
|
|
934
1070
|
...(session.thinkingLevel
|
|
@@ -947,6 +1083,7 @@ export class ConsolePiRuntime {
|
|
|
947
1083
|
* composed operator-chat base prompt. Never returns empty.
|
|
948
1084
|
*/
|
|
949
1085
|
async getRuntimeSystemPrompt(sessionId) {
|
|
1086
|
+
await this.ensureSessionReady(sessionId).catch(() => undefined);
|
|
950
1087
|
const session = this.sessions.get(sessionId);
|
|
951
1088
|
if (!session)
|
|
952
1089
|
return composeOperatorChatSystemPrompt({});
|
|
@@ -978,6 +1115,19 @@ export class ConsolePiRuntime {
|
|
|
978
1115
|
* duplicate reload) — never aborts, never silently queues.
|
|
979
1116
|
*/
|
|
980
1117
|
async reloadSession(sessionId) {
|
|
1118
|
+
try {
|
|
1119
|
+
await this.ensureSessionReady(sessionId);
|
|
1120
|
+
}
|
|
1121
|
+
catch (error) {
|
|
1122
|
+
if (error instanceof PiSessionInitFailedError) {
|
|
1123
|
+
return { ok: false, code: error.code, message: error.message };
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
ok: false,
|
|
1127
|
+
code: "PI_SESSION_NOT_FOUND",
|
|
1128
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
981
1131
|
const session = this.sessions.get(sessionId);
|
|
982
1132
|
if (!session) {
|
|
983
1133
|
return {
|
|
@@ -1049,6 +1199,15 @@ export class ConsolePiRuntime {
|
|
|
1049
1199
|
* session is not active — callers fail closed instead of fabricating data.
|
|
1050
1200
|
*/
|
|
1051
1201
|
async getRuntimeSnapshot(sessionId, options) {
|
|
1202
|
+
try {
|
|
1203
|
+
await this.ensureSessionReady(sessionId);
|
|
1204
|
+
}
|
|
1205
|
+
catch (error) {
|
|
1206
|
+
if (error instanceof PiSessionInitFailedError)
|
|
1207
|
+
throw error;
|
|
1208
|
+
// Inactive / unknown session: fail closed with undefined (no fabricated snapshot).
|
|
1209
|
+
return undefined;
|
|
1210
|
+
}
|
|
1052
1211
|
const session = this.sessions.get(sessionId);
|
|
1053
1212
|
if (!session)
|
|
1054
1213
|
return undefined;
|
|
@@ -1316,6 +1475,24 @@ export class ConsolePiRuntime {
|
|
|
1316
1475
|
*/
|
|
1317
1476
|
async reopenSession(init) {
|
|
1318
1477
|
const sessionId = init.sessionId;
|
|
1478
|
+
// Wait for in-flight create materialization rather than racing reopen.
|
|
1479
|
+
const initState = this.sessionInit.get(sessionId);
|
|
1480
|
+
if (initState?.status === "initializing") {
|
|
1481
|
+
try {
|
|
1482
|
+
await initState.promise;
|
|
1483
|
+
}
|
|
1484
|
+
catch {
|
|
1485
|
+
// failure recorded on sessionInit
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
const failed = this.sessionInit.get(sessionId);
|
|
1489
|
+
if (failed?.status === "failed") {
|
|
1490
|
+
return {
|
|
1491
|
+
ok: false,
|
|
1492
|
+
code: "PI_SESSION_INIT_FAILED",
|
|
1493
|
+
message: failed.error.message,
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1319
1496
|
// Already active in-memory: idempotent reopen is a no-op success.
|
|
1320
1497
|
if (this.sessions.has(sessionId)) {
|
|
1321
1498
|
return { ok: true, handle: await this.snapshotActive(sessionId) };
|
|
@@ -1386,9 +1563,11 @@ export class ConsolePiRuntime {
|
|
|
1386
1563
|
this.sessions.set(sessionId, session);
|
|
1387
1564
|
this.sessionManagers.set(sessionId, sessionManager);
|
|
1388
1565
|
this.modelRuntimes.set(sessionId, built.modelRuntime);
|
|
1566
|
+
this.sessionModels.set(sessionId, init.model);
|
|
1389
1567
|
this.serviceScopes.set(sessionId, built.services);
|
|
1390
1568
|
this.revisions.set(sessionId, 1);
|
|
1391
1569
|
this.thinkingSelections.set(sessionId, "auto");
|
|
1570
|
+
this.sessionInit.set(sessionId, { status: "ready" });
|
|
1392
1571
|
return {
|
|
1393
1572
|
ok: true,
|
|
1394
1573
|
handle: {
|
|
@@ -1411,19 +1590,29 @@ export class ConsolePiRuntime {
|
|
|
1411
1590
|
/** Build a handle for an already-active session (idempotent reopen path). */
|
|
1412
1591
|
async snapshotActive(sessionId) {
|
|
1413
1592
|
const session = this.sessions.get(sessionId);
|
|
1593
|
+
const stored = this.sessionModels.get(sessionId);
|
|
1594
|
+
const fromSession = session.model?.provider
|
|
1595
|
+
? {
|
|
1596
|
+
provider: session.model.provider,
|
|
1597
|
+
modelId: session.model.id ??
|
|
1598
|
+
session.model.modelId ??
|
|
1599
|
+
"",
|
|
1600
|
+
}
|
|
1601
|
+
: undefined;
|
|
1602
|
+
const model = stored ??
|
|
1603
|
+
(fromSession?.modelId ? fromSession : undefined);
|
|
1414
1604
|
return {
|
|
1415
1605
|
sessionId,
|
|
1416
1606
|
sessionFile: session.sessionFile,
|
|
1417
1607
|
createdAt: new Date().toISOString(),
|
|
1418
|
-
model
|
|
1608
|
+
model,
|
|
1419
1609
|
activeTools: session.getActiveToolNames(),
|
|
1420
1610
|
};
|
|
1421
1611
|
}
|
|
1422
1612
|
/**
|
|
1423
|
-
* Shared services + pinned-tool materialization for
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
* excludeTools / tools policy (M0-A three-gate).
|
|
1613
|
+
* Shared services + pinned-tool materialization for reopen/fork paths.
|
|
1614
|
+
* Create uses startMaterialization instead so the durable shell can return
|
|
1615
|
+
* before AgentSession is ready. Tool definitions reuse runtime-level caches.
|
|
1427
1616
|
*/
|
|
1428
1617
|
async buildSessionWithServices(init) {
|
|
1429
1618
|
const agentDir = await safeGetAgentDir(this.bindings);
|
|
@@ -1447,27 +1636,7 @@ export class ConsolePiRuntime {
|
|
|
1447
1636
|
}) ?? undefined)
|
|
1448
1637
|
: undefined;
|
|
1449
1638
|
assertNoWriteToolInList(OPERATOR_CHAT_ALLOWED_TOOLS);
|
|
1450
|
-
|
|
1451
|
-
if (this.options.actionContext) {
|
|
1452
|
-
try {
|
|
1453
|
-
operatorCustomTools = await buildCustomOperatorTools({
|
|
1454
|
-
actionContext: this.options.actionContext,
|
|
1455
|
-
});
|
|
1456
|
-
}
|
|
1457
|
-
catch (error) {
|
|
1458
|
-
process.stderr.write(`[console] chat custom-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
let exploreCustomTools = [];
|
|
1462
|
-
try {
|
|
1463
|
-
exploreCustomTools = await buildExploreCustomTools({
|
|
1464
|
-
repoRoot: this.options.cwd,
|
|
1465
|
-
});
|
|
1466
|
-
}
|
|
1467
|
-
catch (error) {
|
|
1468
|
-
process.stderr.write(`[console] chat explore-tools build failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1469
|
-
}
|
|
1470
|
-
const customTools = [...operatorCustomTools, ...exploreCustomTools];
|
|
1639
|
+
const customTools = await this.loadCustomTools();
|
|
1471
1640
|
const { session } = await this.bindings.createSessionFromServices({
|
|
1472
1641
|
services,
|
|
1473
1642
|
sessionManager: init.sessionManager,
|
|
@@ -1481,7 +1650,7 @@ export class ConsolePiRuntime {
|
|
|
1481
1650
|
"coding_chat",
|
|
1482
1651
|
"shell",
|
|
1483
1652
|
],
|
|
1484
|
-
...(customTools
|
|
1653
|
+
...(customTools.length > 0 ? { customTools } : {}),
|
|
1485
1654
|
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
1486
1655
|
});
|
|
1487
1656
|
return {
|
|
@@ -1506,6 +1675,8 @@ export class ConsolePiRuntime {
|
|
|
1506
1675
|
* (the dispatcher is wired by the HTTP layer, see chat-session.ts).
|
|
1507
1676
|
*/
|
|
1508
1677
|
async prompt(sessionId, text, onEvent, options) {
|
|
1678
|
+
// First message while creating waits on the same materialization promise.
|
|
1679
|
+
await this.ensureSessionReady(sessionId);
|
|
1509
1680
|
const session = this.sessions.get(sessionId);
|
|
1510
1681
|
if (!session) {
|
|
1511
1682
|
throw new Error(`chat session not found: ${sessionId}`);
|
|
@@ -1575,6 +1746,92 @@ export class ConsolePiRuntime {
|
|
|
1575
1746
|
unsub();
|
|
1576
1747
|
}
|
|
1577
1748
|
}
|
|
1749
|
+
/** Generate exactly one title in an isolated in-memory, tool-less LOW session. */
|
|
1750
|
+
async generateTitle(sessionId, firstUserText) {
|
|
1751
|
+
if (this.titleInFlight.has(sessionId))
|
|
1752
|
+
return undefined;
|
|
1753
|
+
this.titleInFlight.add(sessionId);
|
|
1754
|
+
let session;
|
|
1755
|
+
let unsubscribe;
|
|
1756
|
+
let timeout;
|
|
1757
|
+
try {
|
|
1758
|
+
const { services } = await this.bindings.createServices({
|
|
1759
|
+
cwd: this.options.cwd,
|
|
1760
|
+
agentDir: await safeGetAgentDir(this.bindings),
|
|
1761
|
+
// The detached title worker has a closed title-only resource surface:
|
|
1762
|
+
// no project/user context, extensions, skills, prompt templates, or
|
|
1763
|
+
// themes. Its sole system prompt is deliberately minimal; never append
|
|
1764
|
+
// the operator-chat prompt or any loaded resource text.
|
|
1765
|
+
resourceLoaderOptions: {
|
|
1766
|
+
noContextFiles: true,
|
|
1767
|
+
noSkills: true,
|
|
1768
|
+
noExtensions: true,
|
|
1769
|
+
noPromptTemplates: true,
|
|
1770
|
+
noThemes: true,
|
|
1771
|
+
systemPrompt: "Generate one concise plain-text session title in the user's primary language. Return only the title.",
|
|
1772
|
+
},
|
|
1773
|
+
});
|
|
1774
|
+
const low = await resolveLowChatModel(this.options.cwd, this.bindings.listAvailableModels({ modelRuntime: services.modelRuntime }));
|
|
1775
|
+
if (!low)
|
|
1776
|
+
return undefined;
|
|
1777
|
+
const model = this.bindings.resolveModel({
|
|
1778
|
+
modelRuntime: services.modelRuntime,
|
|
1779
|
+
provider: low.provider,
|
|
1780
|
+
modelId: low.modelId,
|
|
1781
|
+
});
|
|
1782
|
+
if (!model)
|
|
1783
|
+
return undefined;
|
|
1784
|
+
if (!this.bindings.createInMemorySessionManager)
|
|
1785
|
+
return undefined;
|
|
1786
|
+
const sessionManager = await this.bindings.createInMemorySessionManager({
|
|
1787
|
+
cwd: this.options.cwd,
|
|
1788
|
+
});
|
|
1789
|
+
const created = await this.bindings.createSessionFromServices({
|
|
1790
|
+
services,
|
|
1791
|
+
sessionManager,
|
|
1792
|
+
noTools: "all",
|
|
1793
|
+
tools: [],
|
|
1794
|
+
customTools: [],
|
|
1795
|
+
model,
|
|
1796
|
+
});
|
|
1797
|
+
session = created.session;
|
|
1798
|
+
let output = "";
|
|
1799
|
+
unsubscribe = session.subscribe((event) => {
|
|
1800
|
+
if (event.type === "message_end")
|
|
1801
|
+
output = extractAssistantText(event.message);
|
|
1802
|
+
});
|
|
1803
|
+
const controller = new AbortController();
|
|
1804
|
+
timeout = setTimeout(() => controller.abort(), 10_000);
|
|
1805
|
+
await Promise.race([
|
|
1806
|
+
session.prompt(`User message:\n${firstUserText.slice(0, 1200)}`, { signal: controller.signal }),
|
|
1807
|
+
new Promise((_, reject) => controller.signal.addEventListener("abort", () => reject(new Error("TITLE_TIMEOUT")), { once: true })),
|
|
1808
|
+
]);
|
|
1809
|
+
return output;
|
|
1810
|
+
}
|
|
1811
|
+
catch {
|
|
1812
|
+
return undefined;
|
|
1813
|
+
}
|
|
1814
|
+
finally {
|
|
1815
|
+
if (timeout)
|
|
1816
|
+
clearTimeout(timeout);
|
|
1817
|
+
unsubscribe?.();
|
|
1818
|
+
try {
|
|
1819
|
+
await session?.abort?.();
|
|
1820
|
+
}
|
|
1821
|
+
catch { /* abort converges before disposal */ }
|
|
1822
|
+
try {
|
|
1823
|
+
session?.dispose();
|
|
1824
|
+
}
|
|
1825
|
+
catch { /* isolated title cleanup is best-effort */ }
|
|
1826
|
+
this.titleInFlight.delete(sessionId);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
/** Pi session_info mirror after durable automatic-title persistence.
|
|
1830
|
+
* Callers own the detached catch boundary so synchronous throws and rejected
|
|
1831
|
+
* mirror Promises are both observed without affecting durable Chat state. */
|
|
1832
|
+
async setSessionName(sessionId, title) {
|
|
1833
|
+
await this.sessions.get(sessionId)?.setSessionName?.(title);
|
|
1834
|
+
}
|
|
1578
1835
|
async compact(sessionId, customInstructions) {
|
|
1579
1836
|
const session = this.sessions.get(sessionId);
|
|
1580
1837
|
if (!session)
|
|
@@ -1587,6 +1844,9 @@ export class ConsolePiRuntime {
|
|
|
1587
1844
|
return projectCompactSnapshot(result ?? {});
|
|
1588
1845
|
}
|
|
1589
1846
|
dispose(sessionId) {
|
|
1847
|
+
// Mark disposed first so a late materialization never registers ready.
|
|
1848
|
+
this.disposedSessions.add(sessionId);
|
|
1849
|
+
this.sessionInit.delete(sessionId);
|
|
1590
1850
|
const session = this.sessions.get(sessionId);
|
|
1591
1851
|
if (session) {
|
|
1592
1852
|
try {
|
|
@@ -1595,15 +1855,16 @@ export class ConsolePiRuntime {
|
|
|
1595
1855
|
catch {
|
|
1596
1856
|
// ignore
|
|
1597
1857
|
}
|
|
1598
|
-
this.sessions.delete(sessionId);
|
|
1599
|
-
this.sessionManagers.delete(sessionId);
|
|
1600
|
-
this.mainlineLeaves.delete(sessionId);
|
|
1601
|
-
this.modelRuntimes.delete(sessionId);
|
|
1602
|
-
this.serviceScopes.delete(sessionId);
|
|
1603
|
-
this.revisions.delete(sessionId);
|
|
1604
|
-
this.thinkingSelections.delete(sessionId);
|
|
1605
|
-
this.reloadInFlight.delete(sessionId);
|
|
1606
1858
|
}
|
|
1859
|
+
this.sessions.delete(sessionId);
|
|
1860
|
+
this.sessionManagers.delete(sessionId);
|
|
1861
|
+
this.mainlineLeaves.delete(sessionId);
|
|
1862
|
+
this.modelRuntimes.delete(sessionId);
|
|
1863
|
+
this.sessionModels.delete(sessionId);
|
|
1864
|
+
this.serviceScopes.delete(sessionId);
|
|
1865
|
+
this.revisions.delete(sessionId);
|
|
1866
|
+
this.thinkingSelections.delete(sessionId);
|
|
1867
|
+
this.reloadInFlight.delete(sessionId);
|
|
1607
1868
|
}
|
|
1608
1869
|
disposeAll() {
|
|
1609
1870
|
for (const id of [...this.sessions.keys()])
|
|
@@ -1753,6 +2014,10 @@ export function createDefaultPiSdkBindings() {
|
|
|
1753
2014
|
id: opts.sessionId,
|
|
1754
2015
|
});
|
|
1755
2016
|
},
|
|
2017
|
+
createInMemorySessionManager: async (opts) => {
|
|
2018
|
+
const sdk = (await loadSdk());
|
|
2019
|
+
return sdk.SessionManager.inMemory(opts.cwd);
|
|
2020
|
+
},
|
|
1756
2021
|
forkSessionManager: async (opts) => {
|
|
1757
2022
|
const sdk = (await loadSdk());
|
|
1758
2023
|
return sdk.SessionManager.forkFrom(opts.sourcePath, opts.targetCwd, opts.targetSessionDir, { id: opts.targetSessionId });
|