march-cli 0.1.38 → 0.1.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -2,7 +2,7 @@ import { createAgentSession, ModelRegistry, SettingsManager } from "@earendil-wo
2
2
  import { createMarchAuthStorage } from "../auth/storage.mjs";
3
3
  import { ContextEngine } from "../context/engine.mjs";
4
4
  import { createMarchLifecycleAdapter } from "../extensions/lifecycle-adapter.mjs";
5
- import { syncPiSessionSidecar } from "../session/sidecar-sync.mjs";
5
+ import { syncMarchSessionState } from "../session/state/march-session-sync.mjs";
6
6
  import { LspService } from "../lsp/service.mjs";
7
7
  import { formatLspServiceEvent } from "../lsp/status-message.mjs";
8
8
  import { estimateProviderPayloadTokens, installModelPayloadDumper, replaceProviderContextMessages } from "./model-payload-dumper.mjs";
@@ -30,7 +30,7 @@ import { appendRunnerTurnHistory, createRunnerHistoryStore } from "../history/ru
30
30
  export { MARCH_BASE_TOOL_NAMES, installModelPayloadDumper };
31
31
  export { createDefaultSessionManager, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
32
32
  export { getRunnerSessionStats, syncEngineSessionState } from "./runner/runner-session-state.mjs";
33
- export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, profilePaths = null, memoryStore = null, memoryTools = [], remoteMemorySources = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, modelContextDumper = null, turnNotifier = null, logger = null, onModelPayload = null, onLspStatusChange = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, hostedTools = {}, notificationContext = null }) {
33
+ export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, profilePaths = null, memoryStore = null, memoryTools = [], remoteMemorySources = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncMarchSessionState: syncMarchSessionStateEnabled = false, syncPiSidecar = syncMarchSessionStateEnabled, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, modelContextDumper = null, turnNotifier = null, logger = null, onModelPayload = null, onLspStatusChange = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, hostedTools = {}, notificationContext = null }) {
34
34
  installRunnerProcessGuards();
35
35
  if (!useRuntimeHost && extensionPaths.length > 0) throw new Error("--extension requires the default pi runtime host path");
36
36
  const authConfig = authStorage ? { authStorage, hasAuth: true } : createMarchAuthStorage({ provider: provider ?? "deepseek", providers, cwd });
@@ -125,7 +125,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
125
125
  setModelCallKind: (kind) => { currentModelCallKind = kind; },
126
126
  logger: turnLog.logger,
127
127
  setPhase: turnLog.setPhase,
128
- syncCurrentPiSidecar,
128
+ syncCurrentMarchSessionState,
129
129
  autoNameSession,
130
130
  contextMode,
131
131
  recordHistory: (turn) => appendRunnerTurnHistory({ store: historyStore, turn, sessionStats: getRunnerSessionStats(sessionBinding.get(), runtimeHost), modelId: engine.modelId, provider: engine.provider }),
@@ -206,14 +206,14 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
206
206
  const activeSession = sessionBinding.get();
207
207
  activeSession.setSessionName?.(name);
208
208
  engine.setSessionName(name);
209
- syncCurrentPiSidecar();
209
+ syncCurrentMarchSessionState();
210
210
  return engine.sessionName;
211
211
  },
212
212
  canSwitchPiSession() { return Boolean(runtimeHost); },
213
213
  async startNewSession() {
214
214
  if (!runtimeHost) throw new Error("pi runtime host is not enabled");
215
215
  nextTurnContextMode = "rebuild";
216
- syncCurrentPiSidecar();
216
+ syncCurrentMarchSessionState();
217
217
  const result = await runtimeHost.newSession();
218
218
  if (result?.cancelled) return { cancelled: true };
219
219
  engine.restoreSession({}, [], { replace: true });
@@ -256,9 +256,9 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
256
256
  },
257
257
  };
258
258
  return runner;
259
- function syncCurrentPiSidecar() {
260
- return syncPiSessionSidecar({
261
- enabled: syncPiSidecar, projectMarchDir, engine,
259
+ function syncCurrentMarchSessionState() {
260
+ return syncMarchSessionState({
261
+ enabled: syncPiSidecar || syncMarchSessionStateEnabled, projectMarchDir, engine,
262
262
  sessionStats: getRunnerSessionStats(sessionBinding.get(), runtimeHost),
263
263
  });
264
264
  }
@@ -72,7 +72,7 @@ export async function createIsolatedRunner(options = {}, deps = {}) {
72
72
  enabled: true,
73
73
  }),
74
74
  useRuntimeHost: true,
75
- syncPiSidecar: true,
75
+ syncMarchSessionState: true,
76
76
  lifecycleHooks: options.lifecycleHooks ?? [],
77
77
  lifecycleDiagnostics: options.lifecycleDiagnostics ?? [],
78
78
  authStorage: d.createMarchAuthStorage({
@@ -14,7 +14,7 @@ export async function runRunnerTurn({
14
14
  setModelCallKind,
15
15
  logger = null,
16
16
  setPhase = null,
17
- syncCurrentPiSidecar,
17
+ syncCurrentMarchSessionState,
18
18
  autoNameSession,
19
19
  contextMode = "rebuild",
20
20
  recordHistory = null,
@@ -79,7 +79,7 @@ export async function runRunnerTurn({
79
79
  ui,
80
80
  turnState,
81
81
  midTurnRecallHints,
82
- syncCurrentPiSidecar,
82
+ syncCurrentMarchSessionState,
83
83
  autoNameSession,
84
84
  recordHistory,
85
85
  });
@@ -129,7 +129,7 @@ function logSessionEvent(logger, event) {
129
129
  });
130
130
  }
131
131
 
132
- function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentPiSidecar, autoNameSession, recordHistory }) {
132
+ function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentMarchSessionState, autoNameSession, recordHistory }) {
133
133
  closeAssistantReply({ ui, state: turnState });
134
134
  const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
135
135
  engine.setPendingAssistantRecallHints?.(assistantRecallHints);
@@ -145,7 +145,7 @@ function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, me
145
145
  recordHistory?.({ ...turn, thinking: assistantThinkingText(turnState), toolCalls: turnState.toolCalls });
146
146
 
147
147
  autoNameSession?.();
148
- syncCurrentPiSidecar();
148
+ syncCurrentMarchSessionState();
149
149
  }
150
150
 
151
151
  function flushAssistantRecall({ memoryStore, engine, turnState, currentProject }) {
package/src/cli/args.mjs CHANGED
@@ -26,6 +26,7 @@ export function parseCliArgs(argv) {
26
26
  workspace: { type: "string" },
27
27
  dev: { type: "boolean" },
28
28
  help: { type: "boolean", short: "h" },
29
+ version: { type: "boolean", short: "v" },
29
30
  },
30
31
  allowPositionals: true,
31
32
  });
@@ -55,6 +56,7 @@ export function parseCliArgs(argv) {
55
56
  workspace: values.workspace ?? null,
56
57
  dev: values.dev ?? false,
57
58
  help: values.help ?? false,
59
+ version: values.version ?? false,
58
60
  prompt: commandName ? "" : positionals.join(" "),
59
61
  };
60
62
  }
@@ -105,5 +107,6 @@ Options:
105
107
  --name <name> With memory serve/add, remote memory source name
106
108
  --foreground With memory serve, run server in current process
107
109
  -h, --help Show this help
110
+ -v, --version Show the March CLI version
108
111
  `);
109
112
  }
@@ -15,6 +15,7 @@ export function handleModeCommand(command, { modeState } = {}) {
15
15
 
16
16
  if (command.type === "set") {
17
17
  modeState.set(command.mode);
18
+ return [];
18
19
  }
19
20
 
20
21
  return [`Mode: ${formatModeLabel(modeState.get())}`];
@@ -227,10 +227,9 @@ function parsedCommand({ names, metadata, parse, run }) {
227
227
  function sessionSourceCommand() {
228
228
  return {
229
229
  metadata: [
230
- { name: "session", description: "Open previous session selector" },
231
230
  { name: "save", description: "Show auto-save status" },
232
231
  ],
233
- match: (trimmed) => (trimmed === "/session" || trimmed === "/save") ? { parsed: { trimmed } } : null,
232
+ match: (trimmed) => trimmed === "/save" ? { parsed: { trimmed } } : null,
234
233
  run: async (ctx, { trimmed }) => handleSessionSourceCommand(trimmed, ctx),
235
234
  };
236
235
  }
@@ -284,7 +283,7 @@ function writeLines(ui, lines) {
284
283
  export function formatHelpLines() {
285
284
  return [
286
285
  `Commands: ${getHelpCommandSyntaxes().join(", ")}`,
287
- "Sessions: /session opens previous sessions and restores the selected one.",
286
+ "Sessions: /session opens the workspace session selector.",
288
287
  "Shortcuts: Tab = toggle Do/Discuss, Esc = abort turn, Ctrl+C = abort turn / press twice to exit when idle, Ctrl+O = toggle tool output, Alt+S = shell pane, Alt+N = next shell, Alt+K/J = shell scroll, PageUp/PageDown = output scroll, Ctrl+G = external editor, Shift+Tab = thinking selector, Ctrl+T = thinking selector, Ctrl+L = model selector",
289
288
  ];
290
289
  }
@@ -57,7 +57,7 @@ export function formatHotkeysPanel(keybindings = DEFAULT_KEYBINDINGS, diagnostic
57
57
  ...formatKeybindingDiagnostics(diagnostics),
58
58
  "Input prefixes:",
59
59
  " / Slash command autocomplete",
60
- " /session Restore a previous session",
60
+ " /session Open workspace session selector",
61
61
  " /thinking Choose or list/set thinking level",
62
62
  " @ File path autocomplete",
63
63
  " ! cmd Run local shell command without sending to the model",
@@ -105,7 +105,7 @@ export async function runInteractiveRepl({
105
105
  }
106
106
 
107
107
  if (turnActive.turnTask) {
108
- ui.writeln("This session is still running. Use /switch to start or inspect another session.");
108
+ ui.writeln("This session is still running. Use /session to start or inspect another session.");
109
109
  continue;
110
110
  }
111
111
 
@@ -1,4 +1,4 @@
1
- import { loadPiSessionSidecar } from "../../session/sidecar.mjs";
1
+ import { loadMarchSessionStateForPiBackend } from "../../session/state/march-session-state.mjs";
2
2
 
3
3
  export async function resumePiSessionById(id, { runner, sessions, projectMarchDir }) {
4
4
  if (!runner.canSwitchPiSession?.()) {
@@ -12,21 +12,21 @@ export async function resumePiSessionById(id, { runner, sessions, projectMarchDi
12
12
  }
13
13
 
14
14
  const session = matches[0];
15
- let sidecar;
15
+ let stored;
16
16
  try {
17
- sidecar = loadPiSessionSidecar({ projectMarchDir, sessionRef: session.path });
17
+ stored = loadMarchSessionStateForPiBackend({ projectMarchDir, sessionId: session.id, sessionRef: session.path });
18
18
  } catch (err) {
19
- return [`Error: pi session sidecar is invalid for ${session.id}: ${err.message}`];
19
+ return [`Error: March session state is invalid for ${session.id}: ${err.message}`];
20
20
  }
21
- if (!sidecar) {
22
- return [`Error: pi session sidecar not found for ${session.id}; refusing partial resume`];
21
+ if (!stored) {
22
+ return [`Error: March session state not found for ${session.id}; refusing partial resume`];
23
23
  }
24
- if (sidecar.state.cwd && sidecar.state.cwd !== runner.engine.cwd) {
25
- return [`Error: pi session sidecar cwd mismatch for ${session.id}: ${sidecar.state.cwd}`];
24
+ if (stored.state.cwd && stored.state.cwd !== runner.engine.cwd) {
25
+ return [`Error: March session state cwd mismatch for ${session.id}: ${stored.state.cwd}`];
26
26
  }
27
27
 
28
28
  let result;
29
- const restoreState = toContextSessionState(sidecar.state);
29
+ const restoreState = toContextSessionState(stored.state);
30
30
  try {
31
31
  result = await runner.switchPiSession(session.path, restoreState);
32
32
  } catch (err) {
@@ -36,6 +36,6 @@ export async function resumePiSessionById(id, { runner, sessions, projectMarchDi
36
36
  return [`Resumed pi session: ${session.id}`];
37
37
  }
38
38
 
39
- function toContextSessionState(sidecarState) {
40
- return { ...sidecarState };
39
+ function toContextSessionState(sessionState) {
40
+ return { ...sessionState };
41
41
  }
@@ -23,7 +23,7 @@ export function formatPiSessionList(sessions) {
23
23
  const savedAt = session.savedAt?.slice(0, 19) ?? "?";
24
24
  return ` ${session.id} ${session.turnCount}m ${savedAt} ${label}`;
25
25
  });
26
- lines.push("(pi JSONL session files; use /session to restore a previous session)");
26
+ lines.push("(pi JSONL session files)");
27
27
  return lines;
28
28
  }
29
29
 
@@ -1,13 +1,7 @@
1
- import { listPiSessionInfos } from "../../session/pi-manager.mjs";
2
- import { loadPiSessionTranscriptTurns } from "../../session/transcript.mjs";
3
- import { resumePiSessionById } from "./pi-session-switch-command.mjs";
4
-
5
1
  export async function handleSessionSourceCommand(trimmed, {
6
2
  ui,
7
3
  runner,
8
4
  sessionState,
9
- sessionsRoot,
10
- projectMarchDir,
11
5
  }) {
12
6
  if (trimmed === "/save") {
13
7
  const stats = runner.getSessionStats?.();
@@ -15,75 +9,5 @@ export async function handleSessionSourceCommand(trimmed, {
15
9
  return { handled: true };
16
10
  }
17
11
 
18
- if (trimmed === "/session") {
19
- const sessions = await listPiSessionInfos({
20
- cwd: runner.engine.cwd,
21
- projectMarchDir,
22
- });
23
- if (sessions.length === 0) {
24
- ui.writeln("No previous sessions.");
25
- return { handled: true };
26
- }
27
- if (!ui.selectList) {
28
- ui.writeln("Session selector is only available in TUI.");
29
- return { handled: true };
30
- }
31
- const currentSessionId = runner.getSessionStats?.().sessionId ?? null;
32
- const item = await ui.selectList({
33
- items: buildSessionSelectItems(sessions, currentSessionId),
34
- selectedIndex: Math.max(0, sessions.findIndex((session) => session.id === currentSessionId)),
35
- width: 72,
36
- suppressInitialConfirm: true,
37
- searchable: true,
38
- getSearchText: sessionSelectSearchText,
39
- });
40
- if (!item) {
41
- ui.writeln("Session unchanged.");
42
- return { handled: true };
43
- }
44
- const lines = await resumePiSessionById(item.session.id, { runner, sessions, projectMarchDir });
45
- if (isResumeSuccess(lines)) restoreTranscriptFromSession(item.session, ui);
46
- for (const line of lines) {
47
- ui.writeln(line);
48
- }
49
- return { handled: true };
50
- }
51
-
52
12
  return { handled: false };
53
13
  }
54
-
55
- export function buildSessionSelectItems(sessions, currentSessionId = null) {
56
- return sessions.map((session) => {
57
- const label = session.name || session.firstMessage || "(no messages)";
58
- const savedAt = formatSessionSelectTime(session.savedAt);
59
- return {
60
- value: session.id,
61
- label,
62
- description: savedAt,
63
- session,
64
- };
65
- });
66
- }
67
-
68
- function sessionSelectSearchText(item) {
69
- const session = item?.session;
70
- return `${item?.label ?? ""} ${item?.description ?? ""} ${session?.id ?? ""} ${session?.name ?? ""} ${session?.firstMessage ?? ""} ${session?.turnCount ?? ""}`;
71
- }
72
-
73
- function restoreTranscriptFromSession(session, ui) {
74
- if (typeof ui.restoreTranscript !== "function") return;
75
- try {
76
- ui.restoreTranscript(loadPiSessionTranscriptTurns(session.path));
77
- } catch (err) {
78
- ui.writeln(`Warning: failed to restore session transcript: ${err.message}`);
79
- }
80
- }
81
-
82
- function isResumeSuccess(lines) {
83
- return Array.isArray(lines) && lines.some((line) => String(line).startsWith("Resumed pi session:"));
84
- }
85
-
86
- function formatSessionSelectTime(value) {
87
- if (!value) return "?";
88
- return String(value).slice(0, 16).replace("T", " ");
89
- }
@@ -24,6 +24,8 @@ import { listWorkspaceSessions } from "../../workspace/session-index.mjs";
24
24
  import { createWorkspaceSessionSupervisor } from "../../workspace/supervisor.mjs";
25
25
  import { createWorkspaceProjectRuntime } from "../workspace/project-runtime.mjs";
26
26
  import { createWorkspaceOutputRouter } from "../workspace/output-router.mjs";
27
+ import { syncRuntimeSessionStateFromRunner } from "../workspace/runtime-session-state.mjs";
28
+ import { loadMarchSessionRenderTimeline, saveMarchSessionRenderTimeline } from "../../session/state/march-session-ui-state.mjs";
27
29
 
28
30
  export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot } = {}) {
29
31
  if (!existsSync(stateRoot)) mkdirSync(stateRoot, { recursive: true });
@@ -62,6 +64,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
62
64
  const modeState = createModeState();
63
65
  const namespace = loadOrCreateProjectId(projectMarchDir);
64
66
  const currentProjectInfo = registerProject({ stateRoot, rootPath: cwd });
67
+ const projectMarchDirs = new Map([[currentProjectInfo.projectId, projectMarchDir]]);
65
68
  const memoryRoot = resolveMemoryRoot(config.memoryRoot, stateRoot);
66
69
  const profilePaths = defaultProfilePaths();
67
70
  ensureProfileFiles(profilePaths);
@@ -87,7 +90,12 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
87
90
  shellRuntime,
88
91
  historyStore: inputHistoryStore,
89
92
  });
90
- const outputRouter = createWorkspaceOutputRouter({ ui, activeProjectId: currentProjectInfo.projectId, activeSessionId: sessionState.sessionId });
93
+ const outputRouter = createWorkspaceOutputRouter({
94
+ ui,
95
+ activeProjectId: currentProjectInfo.projectId,
96
+ activeSessionId: sessionState.sessionId,
97
+ onRenderTimelineChange: persistRenderTimeline,
98
+ });
91
99
  const runtimeUi = outputRouter.createProjectUi(currentProjectInfo.projectId, () => sessionState.sessionId);
92
100
  let turnRunning = false;
93
101
  let refreshStatusBar = null;
@@ -115,7 +123,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
115
123
  let runner;
116
124
  let workspaceSupervisor = null;
117
125
  const onNotificationActivation = (activation) => {
118
- handleNotificationActivation({ activation, stateRoot, workspaceSupervisor, outputRouter, ui }).catch((err) => ui.writeln(`Notification activation failed: ${err.message}`));
126
+ handleNotificationActivation({ activation, stateRoot, workspaceSupervisor, ui }).catch((err) => ui.writeln(`Notification activation failed: ${err.message}`));
119
127
  };
120
128
  try {
121
129
  runner = await createRuntimeRunner({
@@ -131,6 +139,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
131
139
  memoryStore.close?.();
132
140
  return { ok: false, code: 1, logger };
133
141
  }
142
+ syncRuntimeSessionStateFromRunner(sessionState, runner, sessionsRoot);
134
143
 
135
144
  const initialRuntime = {
136
145
  project: currentProjectInfo,
@@ -148,23 +157,26 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
148
157
  };
149
158
  workspaceSupervisor = createWorkspaceSessionSupervisor({
150
159
  initialRuntime,
151
- createProjectRuntime: (project) => createWorkspaceProjectRuntime({
152
- project,
153
- args,
154
- config,
155
- stateRoot,
156
- memoryRoot,
157
- profilePaths,
158
- createMemoryStore: () => new MarkdownMemoryStore({ root: memoryRoot }),
159
- provider,
160
- serviceTier,
161
- model,
162
- remoteMemorySources,
163
- createUi: (runtimeSessionState) => outputRouter.createProjectUi(project.projectId, () => runtimeSessionState.sessionId),
164
- refreshStatusBar: (...args) => refreshStatusBar?.(...args),
165
- onNotificationActivation,
166
- }),
167
- onActivate: ({ projectId, sessionId }) => outputRouter.setActiveSession(projectId, sessionId),
160
+ createProjectRuntime: (project) => {
161
+ projectMarchDirs.set(project.projectId, resolve(project.rootPath, ".march"));
162
+ return createWorkspaceProjectRuntime({
163
+ project,
164
+ args,
165
+ config,
166
+ stateRoot,
167
+ memoryRoot,
168
+ profilePaths,
169
+ createMemoryStore: () => new MarkdownMemoryStore({ root: memoryRoot }),
170
+ provider,
171
+ serviceTier,
172
+ model,
173
+ remoteMemorySources,
174
+ createUi: (runtimeSessionState) => outputRouter.createProjectUi(project.projectId, () => runtimeSessionState.sessionId),
175
+ refreshStatusBar: (...args) => refreshStatusBar?.(...args),
176
+ onNotificationActivation,
177
+ });
178
+ },
179
+ onActivate: ({ projectId, sessionId }) => outputRouter.setActiveSession(projectId, sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDirs.get(projectId), sessionId) }),
168
180
  });
169
181
  runner = workspaceSupervisor.runner;
170
182
 
@@ -198,9 +210,20 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
198
210
  ui,
199
211
  });
200
212
  workspaceSupervisor.refreshActiveRuntime();
201
- outputRouter.setActiveSession(currentProjectInfo.projectId, sessionState.sessionId);
213
+ outputRouter.setActiveSession(currentProjectInfo.projectId, sessionState.sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDir, sessionState.sessionId) });
202
214
  refreshStatusBar();
203
215
 
216
+ function persistRenderTimeline({ projectId, sessionId, events, event }) {
217
+ if (!sessionId || !shouldPersistRenderEvent(event?.method)) return;
218
+ const routeProjectMarchDir = projectMarchDirs.get(projectId);
219
+ if (!routeProjectMarchDir) return;
220
+ try {
221
+ saveMarchSessionRenderTimeline({ projectMarchDir: routeProjectMarchDir, sessionId, renderTimeline: events });
222
+ } catch {
223
+ // Render persistence is separate from model context; a UI write failure must not corrupt the turn.
224
+ }
225
+ }
226
+
204
227
  return {
205
228
  ok: true,
206
229
  args,
@@ -227,8 +250,20 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
227
250
  setTurnRunning(value) { turnRunning = value; },
228
251
  };
229
252
  }
253
+ function loadStoredRenderTimeline(projectMarchDir, sessionId) {
254
+ if (!projectMarchDir || !sessionId) return null;
255
+ try {
256
+ return loadMarchSessionRenderTimeline({ projectMarchDir, sessionId })?.renderTimeline ?? null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ function shouldPersistRenderEvent(method) {
263
+ return method === "turnEnd" || method === "assistantReplyEnd" || method === "toolEnd" || method === "writeln" || method === "clearOutput";
264
+ }
230
265
 
231
- async function handleNotificationActivation({ activation, stateRoot, workspaceSupervisor, outputRouter, ui }) {
266
+ async function handleNotificationActivation({ activation, stateRoot, workspaceSupervisor, ui }) {
232
267
  if (activation?.type !== "workspace-session" || !activation.projectId) return;
233
268
  if (!workspaceSupervisor) throw new Error("workspace supervisor is not ready");
234
269
  const projects = await listWorkspaceSessions({ stateRoot, currentProjectId: workspaceSupervisor.getActive?.()?.project?.projectId ?? null });
@@ -237,6 +272,5 @@ async function handleNotificationActivation({ activation, stateRoot, workspaceSu
237
272
  projectId: activation.projectId,
238
273
  sessionId: activation.sessionId,
239
274
  });
240
- outputRouter?.replayBufferedCalls?.(activation.projectId, activation.sessionId);
241
275
  ui.writeln(`Activated session from notification: ${runtime.project.displayName}`);
242
276
  }
@@ -3,8 +3,6 @@ import { resolve } from "node:path";
3
3
  import { brightBlack } from "../tui/ui-theme.mjs";
4
4
  import { registerProject, listRegisteredProjects } from "../../workspace/project-registry.mjs";
5
5
  import { buildWorkspaceSessionSelectItems, listWorkspaceSessions, workspaceSessionSearchText } from "../../workspace/session-index.mjs";
6
- import { resumePiSessionById } from "../session/pi-session-switch-command.mjs";
7
- import { loadPiSessionTranscriptTurns } from "../../session/transcript.mjs";
8
6
 
9
7
  export const WORKSPACE_SLASH_COMMANDS = [
10
8
  {
@@ -19,9 +17,9 @@ export const WORKSPACE_SLASH_COMMANDS = [
19
17
  run: async (ctx, command) => writeLines(ctx.ui, await handleProjectCommand(command, ctx)),
20
18
  },
21
19
  {
22
- metadata: [{ name: "switch", description: "Open cross-project session switcher" }],
23
- match: (trimmed) => trimmed === "/switch" ? { parsed: { type: "switch" } } : null,
24
- run: handleSwitchCommand,
20
+ metadata: [{ name: "session", description: "Open workspace session selector" }],
21
+ match: (trimmed) => trimmed === "/session" ? { parsed: { type: "session" } } : null,
22
+ run: handleSessionCommand,
25
23
  },
26
24
  ];
27
25
 
@@ -45,9 +43,9 @@ export async function handleProjectCommand(command, { stateRoot }) {
45
43
  return ["Registered projects:", ...projects.map((project) => `- ${project.displayName} ${brightBlack(project.rootPath)}`)];
46
44
  }
47
45
 
48
- export async function handleSwitchCommand({ stateRoot, currentProjectId, projectMarchDir, runner, workspaceSupervisor, workspaceOutputRouter, ui }) {
46
+ export async function handleSessionCommand({ stateRoot, currentProjectId, runner, workspaceSupervisor, workspaceOutputRouter, ui }) {
49
47
  if (!stateRoot) {
50
- ui.writeln("Session switcher is not available: workspace registry is missing.");
48
+ ui.writeln("Session selector is not available: workspace registry is missing.");
51
49
  return { handled: true };
52
50
  }
53
51
  const projects = await listWorkspaceSessions({ stateRoot, currentProjectId });
@@ -59,7 +57,7 @@ export async function handleSwitchCommand({ stateRoot, currentProjectId, project
59
57
  return { handled: true };
60
58
  }
61
59
  if (!ui.selectList) {
62
- ui.writeln("Session switcher is only available in TUI.");
60
+ ui.writeln("Session selector is only available in TUI.");
63
61
  return { handled: true };
64
62
  }
65
63
  const selectedIndex = Math.max(0, items.findIndex((item) => item.project.current && item.session?.id === currentSessionId));
@@ -82,7 +80,6 @@ export async function handleSwitchCommand({ stateRoot, currentProjectId, project
82
80
  }
83
81
  try {
84
82
  const { result } = await workspaceSupervisor.startNewWorkspaceSession(item.project);
85
- ui.restoreTranscript?.([]);
86
83
  ui.writeln(`Created session: ${item.project.displayName} / ${result?.sessionId ?? "new session"}`);
87
84
  return { handled: true, refreshContextTokens: true, activeChanged: true };
88
85
  } catch (err) {
@@ -93,25 +90,15 @@ export async function handleSwitchCommand({ stateRoot, currentProjectId, project
93
90
  if (workspaceSupervisor) {
94
91
  try {
95
92
  await workspaceSupervisor.activateWorkspaceSession({ project: item.project, session: item.session });
96
- restoreTranscriptFromSession(item.session, ui);
97
- const replayed = ctxReplayBufferedOutput({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
98
- ui.writeln(`Switched to session: ${item.project.displayName} / ${item.session.name || item.session.id}${replayed ? ` (${replayed} buffered events replayed)` : ""}`);
93
+ ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
99
94
  return { handled: true, refreshContextTokens: true, activeChanged: true };
100
95
  } catch (err) {
101
96
  ui.writeln(`Error: ${err.message}`);
102
97
  return { handled: true };
103
98
  }
104
99
  }
105
- if (!item.project.current) {
106
- ui.writeln(`Project switch target indexed: ${item.project.displayName}`);
107
- ui.writeln(brightBlack("Cross-project attach requires the workspace supervisor."));
108
- return { handled: true };
109
- }
110
- const sessions = projects.find((project) => project.current)?.sessions ?? [];
111
- const lines = await resumePiSessionById(item.session.id, { runner, sessions, projectMarchDir });
112
- if (isResumeSuccess(lines)) restoreTranscriptFromSession(item.session, ui);
113
- for (const line of lines) ui.writeln(line);
114
- return { handled: true, refreshContextTokens: isResumeSuccess(lines) };
100
+ ui.writeln("Workspace session activation requires the workspace supervisor.");
101
+ return { handled: true };
115
102
  }
116
103
 
117
104
  function annotateWorkspaceItems(items, runtimeSummaries) {
@@ -124,24 +111,11 @@ function annotateWorkspaceItems(items, runtimeSummaries) {
124
111
  });
125
112
  }
126
113
 
127
- function ctxReplayBufferedOutput({ workspaceOutputRouter, projectId, sessionId }) {
128
- return workspaceOutputRouter?.replayBufferedCalls?.(projectId, sessionId) ?? 0;
129
- }
130
-
131
- function restoreTranscriptFromSession(session, ui) {
132
- if (typeof ui.restoreTranscript !== "function") return;
133
- try {
134
- ui.restoreTranscript(loadPiSessionTranscriptTurns(session.path));
135
- } catch (err) {
136
- ui.writeln(`Warning: failed to restore session transcript: ${err.message}`);
137
- }
114
+ function ctxRenderActiveSession({ workspaceOutputRouter, projectId, sessionId }) {
115
+ return workspaceOutputRouter?.getRenderEventCount?.(projectId, sessionId) ?? 0;
138
116
  }
139
117
 
140
118
  function writeLines(ui, lines) {
141
119
  for (const line of lines) ui.writeln(line);
142
120
  return { handled: true };
143
121
  }
144
-
145
- function isResumeSuccess(lines) {
146
- return Array.isArray(lines) && lines.some((line) => String(line).startsWith("Resumed pi session:"));
147
- }