@giovannijecha/jecode 0.4.0 → 0.6.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.
@@ -0,0 +1,368 @@
1
+ // Durable, workspace-scoped conversation storage.
2
+ //
3
+ // Each node is its own atomically replaced file. The head is advanced only
4
+ // after that node is durable, so a crash leaves either the prior checkpoint or
5
+ // one strictly recoverable mutation -- never an ambiguous partial history.
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
8
+ import * as path from "node:path";
9
+ import { atomicWrite } from "../atomic.js";
10
+ import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
+ import { userDataPath } from "../user-data.js";
12
+ import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_SCHEMA, } from "./codec.js";
13
+ import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
14
+ const DIRECTORY_MODE = 0o700;
15
+ const FILE_MODE = 0o600;
16
+ const MAX_CATALOG_SCAN = 128;
17
+ const MAX_JSON_BYTES = 20 * 1024 * 1024;
18
+ const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
19
+ const NODE_NAME = /^(\d{6})\.json$/;
20
+ const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
21
+ export class DurableSessionStore {
22
+ workspaceRoot;
23
+ workspaceDigest;
24
+ #sessionsRoot;
25
+ #bucket;
26
+ constructor(workspaceRoot, sessionsRoot) {
27
+ this.workspaceRoot = workspaceRoot;
28
+ this.workspaceDigest = digestWorkspace(workspaceRoot);
29
+ this.#sessionsRoot = sessionsRoot;
30
+ this.#bucket = path.join(sessionsRoot, this.workspaceDigest);
31
+ }
32
+ static async open(workspaceRoot, sessionsRoot = userDataPath("sessions")) {
33
+ const canonical = await realpath(path.resolve(workspaceRoot));
34
+ return new DurableSessionStore(canonical, path.resolve(sessionsRoot));
35
+ }
36
+ async list(limit = 32) {
37
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
38
+ throw new Error("session catalogue limit is invalid");
39
+ }
40
+ const entries = await directoryEntries(this.#bucket);
41
+ const names = entries
42
+ .filter((entry) => entry.isDirectory() && SESSION_NAME.test(entry.name))
43
+ .map((entry) => entry.name)
44
+ .sort((left, right) => right.localeCompare(left))
45
+ .slice(0, MAX_CATALOG_SCAN);
46
+ const catalog = [];
47
+ for (const id of names) {
48
+ try {
49
+ const snapshot = await this.load(id);
50
+ const conversation = snapshot.conversation.latestCompleted();
51
+ if (conversation === undefined)
52
+ continue;
53
+ catalog.push({
54
+ id,
55
+ createdAt: snapshot.meta.createdAt,
56
+ updatedAt: snapshot.head.updatedAt,
57
+ turns: selectedTurnCount(conversation),
58
+ preview: firstUserText(conversation),
59
+ active: await this.#leaseIsActive(id),
60
+ });
61
+ }
62
+ catch {
63
+ // Corrupt or foreign data never becomes a resume candidate.
64
+ }
65
+ }
66
+ return catalog
67
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
68
+ .slice(0, limit);
69
+ }
70
+ async load(id) {
71
+ assertSessionId(id);
72
+ const directory = this.#sessionDirectory(id);
73
+ await assertDirectory(directory);
74
+ const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), 64 * 1024));
75
+ if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
76
+ workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
77
+ throw new Error("session belongs to a different workspace");
78
+ let head = decodeHead(await readJson(path.join(directory, "head.json"), 64 * 1024));
79
+ const stored = await readNodes(path.join(directory, "nodes"));
80
+ const ahead = stored.filter((entry) => entry.sequence > head.sequence);
81
+ if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
82
+ throw new Error("session has an ambiguous incomplete checkpoint");
83
+ }
84
+ const byId = new Map(stored.map((entry) => [entry.node.id, entry]));
85
+ const headed = byId.get(head.nodeId);
86
+ if (headed === undefined)
87
+ throw new Error("session head is missing its conversation node");
88
+ if (ahead.length === 0) {
89
+ if (headed.sequence !== head.sequence || headed.node.revision !== head.revision ||
90
+ headed.node.parentId !== head.parentId) {
91
+ throw new Error("session head does not match its conversation node");
92
+ }
93
+ }
94
+ else {
95
+ const candidate = ahead[0];
96
+ const replacesHead = candidate.node.id === head.nodeId &&
97
+ candidate.node.parentId === head.parentId &&
98
+ candidate.node.revision === head.revision + 1;
99
+ const candidateParent = byId.get(candidate.node.parentId);
100
+ const extendsTree = candidate.node.id === stored.length &&
101
+ candidate.node.revision === 1 && candidateParent !== undefined &&
102
+ candidateParent.sequence <= head.sequence;
103
+ if (!replacesHead && !extendsTree) {
104
+ throw new Error("session checkpoint cannot be recovered safely");
105
+ }
106
+ head = {
107
+ version: SESSION_SCHEMA,
108
+ sequence: candidate.sequence,
109
+ nodeId: candidate.node.id,
110
+ parentId: candidate.node.parentId,
111
+ revision: candidate.node.revision,
112
+ updatedAt: candidate.updatedAt,
113
+ };
114
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
115
+ }
116
+ const nodes = stored.map((entry) => entry.node);
117
+ const conversation = ConversationTree.restore(nodes, head.nodeId);
118
+ return Object.freeze({ meta, head, conversation });
119
+ }
120
+ async publish(conversation, claim) {
121
+ const active = conversation.activeNode;
122
+ if (active === undefined)
123
+ throw new Error("an empty conversation cannot be persisted");
124
+ await this.#ensureBucket();
125
+ const now = new Date().toISOString();
126
+ const id = sessionId(now);
127
+ const token = claim === true ? leaseToken() : undefined;
128
+ const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
129
+ const target = this.#sessionDirectory(id);
130
+ const meta = {
131
+ version: SESSION_SCHEMA,
132
+ id,
133
+ workspaceRoot: this.workspaceRoot,
134
+ workspaceDigest: this.workspaceDigest,
135
+ createdAt: now,
136
+ };
137
+ const head = {
138
+ version: SESSION_SCHEMA,
139
+ sequence: conversation.nodes.length,
140
+ nodeId: active.id,
141
+ parentId: active.parentId,
142
+ revision: active.revision,
143
+ updatedAt: now,
144
+ };
145
+ try {
146
+ await makePrivateDirectory(temporary);
147
+ const nodes = path.join(temporary, "nodes");
148
+ await makePrivateDirectory(nodes);
149
+ for (let index = 0; index < conversation.nodes.length; index++) {
150
+ const node = conversation.nodes[index];
151
+ await atomicWrite(path.join(nodes, nodeName(node.id)), encodeNode(node, index + 1, now), { mode: FILE_MODE });
152
+ }
153
+ await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), { mode: FILE_MODE });
154
+ await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), { mode: FILE_MODE });
155
+ if (token !== undefined) {
156
+ await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
157
+ }
158
+ await rename(temporary, target);
159
+ }
160
+ catch (error) {
161
+ await removeTemporaryDirectory(temporary, this.#bucket);
162
+ throw error;
163
+ }
164
+ const snapshot = Object.freeze({ meta, head, conversation });
165
+ if (token === undefined)
166
+ return snapshot;
167
+ const lease = sessionLease(id, path.join(target, "active"), token);
168
+ return Object.freeze({ ...snapshot, lease });
169
+ }
170
+ async checkpoint(id, conversation) {
171
+ const previous = await this.load(id);
172
+ const active = conversation.activeNode;
173
+ if (active === undefined)
174
+ throw new Error("an empty conversation cannot be checkpointed");
175
+ if (sameNode(active, previous.conversation.activeNode)) {
176
+ return Object.freeze({ ...previous, conversation });
177
+ }
178
+ const replacesHead = active.id === previous.head.nodeId &&
179
+ active.revision === previous.head.revision + 1 &&
180
+ conversation.nodes.length === previous.conversation.nodes.length;
181
+ const extendsTree = active.id === previous.conversation.nodes.length + 1 &&
182
+ previous.conversation.node(active.parentId) !== undefined && active.revision === 1 &&
183
+ conversation.nodes.length === previous.conversation.nodes.length + 1;
184
+ if (!replacesHead && !extendsTree) {
185
+ throw new Error("session checkpoint does not extend its durable tree");
186
+ }
187
+ assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
188
+ const now = new Date().toISOString();
189
+ const head = {
190
+ version: SESSION_SCHEMA,
191
+ sequence: previous.head.sequence + 1,
192
+ nodeId: active.id,
193
+ parentId: active.parentId,
194
+ revision: active.revision,
195
+ updatedAt: now,
196
+ };
197
+ const directory = this.#sessionDirectory(id);
198
+ await atomicWrite(path.join(directory, "nodes", nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE });
199
+ await atomicWrite(path.join(directory, "head.json"), encodeHead(head), { mode: FILE_MODE });
200
+ return Object.freeze({ meta: previous.meta, head, conversation });
201
+ }
202
+ async claim(id) {
203
+ assertSessionId(id);
204
+ await assertDirectory(this.#sessionDirectory(id));
205
+ const file = path.join(this.#sessionDirectory(id), "active");
206
+ const token = leaseToken();
207
+ for (let attempt = 0; attempt < 2; attempt++) {
208
+ try {
209
+ const handle = await open(file, "wx", FILE_MODE);
210
+ try {
211
+ await handle.writeFile(token, "utf8");
212
+ await handle.sync();
213
+ }
214
+ finally {
215
+ await handle.close();
216
+ }
217
+ return sessionLease(id, file, token);
218
+ }
219
+ catch (error) {
220
+ if (error.code !== "EEXIST")
221
+ throw error;
222
+ const owner = await leaseOwner(file);
223
+ if (owner !== undefined && pidIsAlive(owner.pid)) {
224
+ throw new Error("session is already open in another Jecode process");
225
+ }
226
+ if (owner !== undefined) {
227
+ await removeLease(file, owner.token);
228
+ }
229
+ }
230
+ }
231
+ throw new Error("session could not be claimed");
232
+ }
233
+ #sessionDirectory(id) {
234
+ return path.join(this.#bucket, id);
235
+ }
236
+ async #ensureBucket() {
237
+ await makePrivateDirectory(this.#sessionsRoot);
238
+ await makePrivateDirectory(this.#bucket);
239
+ }
240
+ async #leaseIsActive(id) {
241
+ const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
242
+ return owner !== undefined && pidIsAlive(owner.pid);
243
+ }
244
+ }
245
+ function assertSharedNodes(previous, next, replacedId) {
246
+ for (const node of previous.nodes) {
247
+ if (node.id === replacedId)
248
+ continue;
249
+ const candidate = next.node(node.id);
250
+ if (candidate === undefined || normalizedNode(candidate) !== normalizedNode(node)) {
251
+ throw new Error("session checkpoint rewrites prior conversation history");
252
+ }
253
+ }
254
+ }
255
+ function normalizedNode(node) {
256
+ const encoded = JSON.parse(encodeNode(node, 1, "2026-01-01T00:00:00.000Z"));
257
+ return JSON.stringify(encoded.node);
258
+ }
259
+ function sameNode(left, right) {
260
+ return right !== undefined && normalizedNode(left) === normalizedNode(right);
261
+ }
262
+ async function readNodes(directory) {
263
+ await assertDirectory(directory);
264
+ const entries = await readdir(directory, { withFileTypes: true });
265
+ const names = entries.filter((entry) => entry.isFile() && NODE_NAME.test(entry.name))
266
+ .map((entry) => entry.name).sort();
267
+ if (names.length === 0 || names.length > CONVERSATION_LIMITS.nodes) {
268
+ throw new Error("session has an invalid conversation size");
269
+ }
270
+ if (entries.length > CONVERSATION_LIMITS.nodes + 64 ||
271
+ entries.some((entry) => !entry.isFile() || (!NODE_NAME.test(entry.name) && !ATOMIC_NODE_TEMP.test(entry.name)))) {
272
+ throw new Error("session node directory contains unsupported data");
273
+ }
274
+ const stored = [];
275
+ const sequences = new Set();
276
+ for (let index = 0; index < names.length; index++) {
277
+ const name = names[index];
278
+ const id = Number(NODE_NAME.exec(name)?.[1]);
279
+ if (id !== index + 1)
280
+ throw new Error("session conversation nodes are not contiguous");
281
+ const decoded = decodeNode(await readJson(path.join(directory, name), MAX_JSON_BYTES));
282
+ if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
283
+ throw new Error("session conversation node identity is invalid");
284
+ }
285
+ sequences.add(decoded.sequence);
286
+ stored.push(decoded);
287
+ }
288
+ return stored;
289
+ }
290
+ async function readJson(file, limit) {
291
+ const details = await lstat(file);
292
+ if (details.isSymbolicLink() || !details.isFile() || details.size > limit) {
293
+ throw new Error("session file is unsafe or too large");
294
+ }
295
+ try {
296
+ return JSON.parse(await readFile(file, "utf8"));
297
+ }
298
+ catch {
299
+ throw new Error("session file is not valid JSON");
300
+ }
301
+ }
302
+ async function assertDirectory(directory) {
303
+ const details = await lstat(directory);
304
+ if (details.isSymbolicLink() || !details.isDirectory()) {
305
+ throw new Error("session path is not a direct directory");
306
+ }
307
+ }
308
+ async function makePrivateDirectory(directory) {
309
+ await mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
310
+ await assertDirectory(directory);
311
+ if (process.platform !== "win32")
312
+ await chmod(directory, DIRECTORY_MODE);
313
+ }
314
+ async function directoryEntries(directory) {
315
+ try {
316
+ await assertDirectory(directory);
317
+ return await readdir(directory, { withFileTypes: true });
318
+ }
319
+ catch (error) {
320
+ if (error.code === "ENOENT")
321
+ return [];
322
+ throw error;
323
+ }
324
+ }
325
+ async function removeTemporaryDirectory(directory, bucket) {
326
+ const relative = path.relative(bucket, directory);
327
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
328
+ !path.basename(directory).startsWith(".") || !path.basename(directory).endsWith(".tmp"))
329
+ throw new Error("refusing to remove an unverified session directory");
330
+ await rm(directory, { recursive: true, force: true });
331
+ }
332
+ function selectedTurnCount(conversation) {
333
+ let count = 0;
334
+ let id = conversation.activeNodeId;
335
+ while (id !== 0) {
336
+ count++;
337
+ id = conversation.node(id)?.parentId ?? 0;
338
+ }
339
+ return count;
340
+ }
341
+ function firstUserText(conversation) {
342
+ for (const message of conversation.history) {
343
+ if (message.role !== "user")
344
+ continue;
345
+ const text = message.content.find((block) => block.kind === "text")?.text
346
+ .replace(/\s+/gu, " ").trim();
347
+ if (text !== undefined && text !== "")
348
+ return text.slice(0, 160);
349
+ }
350
+ return "Untitled session";
351
+ }
352
+ function nodeName(id) {
353
+ return `${String(id).padStart(6, "0")}.json`;
354
+ }
355
+ function sessionId(now) {
356
+ return `${now.replace(/[-:.]/g, "").replace("Z", "Z")}-${randomUUID()}`;
357
+ }
358
+ function digestWorkspace(workspaceRoot) {
359
+ return createHash("sha256").update(workspaceKey(workspaceRoot)).digest("hex");
360
+ }
361
+ function workspaceKey(workspaceRoot) {
362
+ const normalized = path.normalize(workspaceRoot);
363
+ return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
364
+ }
365
+ function assertSessionId(id) {
366
+ if (!SESSION_NAME.test(id))
367
+ throw new Error("session id is invalid");
368
+ }
@@ -1,6 +1,7 @@
1
1
  // The persistent settings hub. Provider access and model selection reuse the
2
2
  // same command flows exposed directly through /providers and /models.
3
3
  import { saveCommandSettings } from "./command-settings.js";
4
+ import { MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT } from "./context/policy.js";
4
5
  import { modelsCommand } from "./model-command.js";
5
6
  import { providerFailure } from "./provider-errors.js";
6
7
  import { providerLabel } from "./provider-label.js";
@@ -43,6 +44,9 @@ export async function settingsCommand(session, host) {
43
44
  case "maxSteps":
44
45
  await numberSetting(session, host, "maxSteps", "max tool steps");
45
46
  break;
47
+ case "compactionPercent":
48
+ await compactionSetting(session, host);
49
+ break;
46
50
  case "reducedMotion":
47
51
  await motionSetting(session, host);
48
52
  break;
@@ -53,9 +57,11 @@ export async function settingsCommand(session, host) {
53
57
  }
54
58
  }
55
59
  export function settingsPicker(values, index = 0) {
60
+ const items = settingsItems(values);
56
61
  return {
57
62
  title: [],
58
- options: settingsItems(values).map((item) => item.option),
63
+ options: items.map((item) => item.option),
64
+ visible: items.length,
59
65
  index,
60
66
  };
61
67
  }
@@ -76,13 +82,17 @@ function settingsItems(values) {
76
82
  option: { label: "max output tokens", value: String(values.maxTokens) },
77
83
  }]),
78
84
  { action: "maxSteps", option: { label: "max tool steps", value: String(values.maxSteps) } },
85
+ {
86
+ action: "compactionPercent",
87
+ option: { label: "context compaction", value: `${values.compactionPercent}%` },
88
+ },
79
89
  {
80
90
  action: "reducedMotion",
81
91
  option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
82
92
  },
83
93
  {
84
94
  action: "providers",
85
- option: { label: "providers", hint: "manage access and connections" },
95
+ option: { label: "providers", hint: "manage connections" },
86
96
  },
87
97
  ];
88
98
  }
@@ -93,6 +103,7 @@ function settingsValues(session) {
93
103
  effort: session.config.effort,
94
104
  ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
95
105
  maxSteps: session.config.maxSteps,
106
+ compactionPercent: session.config.compactionPercent,
96
107
  reducedMotion: session.config.reducedMotion,
97
108
  };
98
109
  }
@@ -185,6 +196,35 @@ async function numberSetting(session, host, name, label) {
185
196
  return;
186
197
  session.config[name] = value;
187
198
  }
199
+ async function compactionSetting(session, host) {
200
+ if (host.type === undefined)
201
+ return;
202
+ const label = "context compaction";
203
+ const field = {
204
+ title: heading(label, `${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT} percent`, session.palette),
205
+ right: "enter save · esc back",
206
+ editor: of(String(session.config.compactionPercent)),
207
+ secret: false,
208
+ note: "Compacts when model context reaches this percentage.",
209
+ };
210
+ const text = await host.type(field);
211
+ if (text === undefined)
212
+ return;
213
+ const value = Number(text);
214
+ if (!Number.isSafeInteger(value) ||
215
+ value < MIN_COMPACTION_PERCENT ||
216
+ value > MAX_COMPACTION_PERCENT) {
217
+ host.emit({
218
+ kind: "notice",
219
+ text: `${label} must be from ${MIN_COMPACTION_PERCENT} to ${MAX_COMPACTION_PERCENT}`,
220
+ tone: "error",
221
+ });
222
+ return;
223
+ }
224
+ if (!(await saveCommandSettings(host, { compactionPercent: value })))
225
+ return;
226
+ session.config.compactionPercent = value;
227
+ }
188
228
  function chooser(host) {
189
229
  if (host.choose === undefined) {
190
230
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
package/dist/settings.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { chmod, mkdir } from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { atomicWrite } from "./atomic.js";
6
+ import { MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT } from "./context/policy.js";
6
7
  import { EFFORTS } from "./effort.js";
7
8
  import { providerNames } from "./providers/index.js";
8
9
  import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
@@ -59,6 +60,7 @@ function normalize(value) {
59
60
  const reducedMotion = typeof value["reducedMotion"] === "boolean" ? value["reducedMotion"] : undefined;
60
61
  const maxTokens = positiveInteger(value["maxTokens"]);
61
62
  const maxSteps = positiveInteger(value["maxSteps"]);
63
+ const compactionPercent = percentage(value["compactionPercent"]);
62
64
  return {
63
65
  ...(provider === undefined ? {} : { provider }),
64
66
  ...(models === undefined ? {} : { models }),
@@ -67,6 +69,7 @@ function normalize(value) {
67
69
  ...(reducedMotion === undefined ? {} : { reducedMotion }),
68
70
  ...(maxTokens === undefined ? {} : { maxTokens }),
69
71
  ...(maxSteps === undefined ? {} : { maxSteps }),
72
+ ...(compactionPercent === undefined ? {} : { compactionPercent }),
70
73
  };
71
74
  }
72
75
  function modelsOf(value, providers) {
@@ -91,6 +94,12 @@ function endpoint(value) {
91
94
  function positiveInteger(value) {
92
95
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
93
96
  }
97
+ function percentage(value) {
98
+ return typeof value === "number" && Number.isSafeInteger(value) &&
99
+ value >= MIN_COMPACTION_PERCENT && value <= MAX_COMPACTION_PERCENT
100
+ ? value
101
+ : undefined;
102
+ }
94
103
  function record(value) {
95
104
  return typeof value === "object" && value !== null && !Array.isArray(value);
96
105
  }
package/dist/start.js CHANGED
@@ -3,12 +3,16 @@ import * as path from "node:path";
3
3
  import { runBatch } from "./batch.js";
4
4
  import { showCliInfo } from "./cli-info.js";
5
5
  import { loadConfig } from "./config.js";
6
+ import { ConversationTree } from "./conversation.js";
7
+ import { parseLaunch } from "./launch.js";
6
8
  import { systemPrompt } from "./prompt.js";
7
9
  import { configureProviders, selectProvider } from "./providers/index.js";
10
+ import { SessionPersistence } from "./sessions/runtime.js";
11
+ import { DurableSessionStore } from "./sessions/store.js";
8
12
  import { builtinTools } from "./tools/index.js";
9
13
  import { configureColor } from "./ui/render.js";
10
14
  import { STEEL } from "./ui/theme.js";
11
- import { emptyUsage } from "./usage.js";
15
+ import { emptyUsage, usageFromHistory } from "./usage.js";
12
16
  import { runApp } from "./tui/app.js";
13
17
  import { interactive } from "./tui/screen.js";
14
18
  export async function start(args = process.argv.slice(2), environment = {}) {
@@ -17,10 +21,17 @@ export async function start(args = process.argv.slice(2), environment = {}) {
17
21
  const write = environment.write ?? ((text) => process.stdout.write(text));
18
22
  if (await showCliInfo(args, applicationRoot, write))
19
23
  return;
20
- const config = loadConfig(args);
24
+ const launch = parseLaunch(args);
25
+ const config = loadConfig(launch.configArgs);
21
26
  configureProviders(config);
22
27
  const provider = selectProvider(config.providerId);
23
28
  const hasScreen = environment.interactive?.() ?? interactive();
29
+ if (launch.kind === "resume" && !hasScreen) {
30
+ throw new Error("resume needs an interactive terminal");
31
+ }
32
+ if (launch.kind === "resume" && config.ephemeral) {
33
+ throw new Error("--ephemeral cannot be combined with resume");
34
+ }
24
35
  // A provider whose catalogue is not fixed has no sensible default model.
25
36
  // The TUI can ask; a pipe cannot, so batch mode still requires one up front.
26
37
  const model = config.model === "" ? provider.defaultModel : config.model;
@@ -35,13 +46,65 @@ export async function start(args = process.argv.slice(2), environment = {}) {
35
46
  palette: STEEL,
36
47
  tools: builtinTools(),
37
48
  system: systemPrompt(config),
38
- history: [],
49
+ conversation: ConversationTree.empty(),
39
50
  usage: emptyUsage(),
40
51
  };
41
52
  if (hasScreen) {
42
- await (environment.runInteractive ?? runApp)(session, transcriptRoot);
53
+ if (!config.ephemeral) {
54
+ const store = await DurableSessionStore.open(config.root, environment.sessionsRoot);
55
+ if (launch.kind === "resume") {
56
+ const candidates = await SessionPersistence.candidates(store);
57
+ if (candidates.length === 0)
58
+ throw new Error("no resumable sessions found for this workspace");
59
+ const open = async (id) => {
60
+ const resumed = await SessionPersistence.resume(store, id);
61
+ try {
62
+ applyResumedSession(session, resumed.conversation, resumed.persistence);
63
+ }
64
+ catch (error) {
65
+ await resumed.persistence.close();
66
+ throw error;
67
+ }
68
+ };
69
+ if (launch.latest)
70
+ await open(candidates[0].id);
71
+ else
72
+ session.resume = { candidates, open };
73
+ }
74
+ else {
75
+ session.persistence = SessionPersistence.fresh(store);
76
+ }
77
+ }
78
+ try {
79
+ await (environment.runInteractive ?? runApp)(session, transcriptRoot);
80
+ }
81
+ finally {
82
+ await session.persistence?.close();
83
+ }
43
84
  }
44
85
  else {
45
86
  await (environment.runNonInteractive ?? runBatch)(session);
46
87
  }
47
88
  }
89
+ function applyResumedSession(session, conversation, persistence) {
90
+ const identity = conversation.activeNode?.identity;
91
+ if (identity === undefined)
92
+ throw new Error("resumed session has no active turn");
93
+ const provider = selectProvider(identity.providerId);
94
+ const config = {
95
+ ...session.config,
96
+ providerId: identity.providerId,
97
+ model: identity.model,
98
+ effort: identity.effort,
99
+ };
100
+ const system = systemPrompt(config);
101
+ const usage = usageFromHistory(conversation.history);
102
+ session.config = config;
103
+ session.provider = provider;
104
+ session.model = identity.model;
105
+ session.system = system;
106
+ session.conversation = conversation;
107
+ session.usage = usage;
108
+ session.persistence = persistence;
109
+ session.resume = undefined;
110
+ }
@@ -0,0 +1,6 @@
1
+ // Provider-neutral transcript vocabulary.
2
+ //
3
+ // The conversation domain stores these settled semantic blocks so a resumed
4
+ // session can rebuild the same screen without persisting terminal escape
5
+ // sequences or renderer state.
6
+ export {};
@@ -9,3 +9,6 @@ export function elapsed(activity, now = Date.now()) {
9
9
  const minutes = Math.floor(seconds / 60);
10
10
  return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
11
11
  }
12
+ export function activityStatus(activity, label = activity.label, now = Date.now()) {
13
+ return `${label} · ${elapsed(activity, now)}`;
14
+ }
@@ -21,6 +21,15 @@ export function appInput(options) {
21
21
  }
22
22
  if (state.feedback !== undefined)
23
23
  feedback.dismiss();
24
+ // Detail expansion remains available while an approval is open. A large
25
+ // diff may be compacted, but the user must be able to inspect it before
26
+ // answering the permission prompt.
27
+ if (key.ctrl && key.name === "o") {
28
+ const changed = toggleDetails(state.blocks);
29
+ if (changed !== undefined)
30
+ options.transcriptChanged(changed);
31
+ return;
32
+ }
24
33
  if (state.open !== undefined) {
25
34
  const outcome = overlay.handle(state.open, key);
26
35
  state.open = outcome.open;
@@ -95,12 +104,6 @@ export function appInput(options) {
95
104
  options.invalidate();
96
105
  return;
97
106
  }
98
- if (key.ctrl && key.name === "o") {
99
- const changed = toggleDetails(state.blocks);
100
- if (changed !== undefined)
101
- options.transcriptChanged(changed);
102
- return;
103
- }
104
107
  const edited = applyKey(state.editor, key);
105
108
  if (edited !== undefined) {
106
109
  state.editor = edited;