@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.
Files changed (26) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/worker/console/chat/model-resolver.js +17 -0
  3. package/dist/worker/console/chat/pi-runtime.js +397 -132
  4. package/dist/worker/console/chat/routes.js +185 -25
  5. package/dist/worker/console/chat/session-store.js +39 -0
  6. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  7. package/dist/worker/console/static/assets/index-CMHovlqG.js +32 -0
  8. package/dist/worker/console/static/index.html +2 -2
  9. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  10. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  11. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  12. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  13. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +109 -22
  14. package/dist/worker/console/static-src/operator-chat/useChatStream.js +6 -1
  15. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  16. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  17. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  18. package/harness.json +1 -1
  19. package/package.json +1 -1
  20. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  21. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  22. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  23. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  24. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  25. package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
  26. 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
- // Build a throwaway services just to read modelRuntime.snapshot. This is
663
- // cheap (SDK caches); tests inject bindings that stub listAvailableModels.
664
- const { services } = await this.bindings.createServices({
665
- cwd: this.options.cwd,
666
- agentDir: await safeGetAgentDir(this.bindings),
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: services.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
- /** Create a new isolated Chat session pinned to the operator-chat tool set. */
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
- const agentDir = await safeGetAgentDir(this.bindings);
687
- // Resolve default model from harness.json executors.pi.MED (no hardcode).
688
- // Caller may pass an explicit model; otherwise we read the repo harness and
689
- // search the SDK available-model list for a credentialled provider.
690
- let model = init?.model;
691
- if (!model) {
692
- const resolved = await this.resolveDefaultModel();
693
- if (resolved)
694
- model = resolved;
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
- // Final fallback only when nothing resolved: let the SDK pick its default
697
- // (we do NOT hardcode a provider here).
698
- // Standard open resource surface (ADR 0012): the SDK DefaultResourceLoader
699
- // loads user/project AGENTS.md context files, skills (~/.pi/agent/skills/
700
- // + .agents/skills/ init projection) and user/project extensions. Only
701
- // appendSystemPrompt carries the operator-chat system prompt.
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?.systemPromptSuffix ? [init.systemPromptSuffix] : []),
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
- // Resolve the concrete SDK model only when we have a descriptor. When no
715
- // model is resolved (harness unset + no snapshot match), we omit `model`
716
- // and let the SDK session use its own default (no hardcoded fallback).
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
- // Build custom Pi tools that route whitelisted operator actions into
734
- // the dispatcher (Gate 3 lives inside each tool's execute()).
735
- let operatorCustomTools = [];
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 && customTools.length > 0 ? { customTools } : {}),
862
+ ...(customTools.length > 0 ? { customTools } : {}),
790
863
  ...(resolvedModel ? { model: resolvedModel } : {}),
791
864
  });
792
- // Gate 2: re-pin active tools (defensive — some SDK paths may seed
793
- // defaults). Active set = operator baseline + built-in explore tools +
794
- // extension tools registered by user/project extensions (ADR 0012).
795
- // Non-Pi write channels are excluded above and cannot appear here.
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.set(sessionId, "auto");
806
- return {
807
- sessionId,
808
- sessionFile: session.sessionFile,
809
- createdAt: new Date().toISOString(),
810
- model,
811
- activeTools: session.getActiveToolNames(),
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 provider = session.model?.provider;
931
- const modelId = session.model?.id ?? session.model?.modelId;
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: undefined,
1608
+ model,
1419
1609
  activeTools: session.getActiveToolNames(),
1420
1610
  };
1421
1611
  }
1422
1612
  /**
1423
- * Shared services + pinned-tool materialization for both createSession and
1424
- * reopenSession. Loads closed-surface services, resolves the model, builds
1425
- * the operator + safe explore customTools, and calls the SDK with the same
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
- let operatorCustomTools = [];
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 && customTools.length > 0 ? { 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 });