@giovannijecha/jecode 0.7.1 → 0.7.2

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/README.md CHANGED
@@ -168,7 +168,7 @@ Type `/` to open searchable command completion inside the composer.
168
168
  | `/providers` | Manage provider connections, API keys, ChatGPT sign-in, and Ollama endpoints |
169
169
  | `/models` | Search all currently usable provider catalogues and select a model |
170
170
  | `/permissions` | Change session tool access and review remembered approvals |
171
- | `/timeline` | Browse completed turns and select where the next branch should begin |
171
+ | `/timeline` | Browse resumable turns and select where the next branch should begin |
172
172
  | `/compact` | Compact the current branch context without deleting saved conversation history |
173
173
  | `/new` | Start a new conversation and reset session tool permissions |
174
174
  | `/export` | Save a timestamped Markdown transcript in the launch directory |
@@ -203,13 +203,16 @@ the resume picker until it has a settled turn. Resuming and continuing a
203
203
  conversation keeps its durable session identity and updates one picker entry
204
204
  instead of creating duplicates.
205
205
 
206
- `/timeline` shows completed turns in the conversation tree. Selecting an older
207
- turn changes only the in-memory path: it creates and saves a branch only after
208
- the next real user message. Cancelling the picker or exiting before that message
209
- leaves the durable head unchanged. Historical tools are displayed but never
210
- executed. If a crash interrupted a tool loop, Jecode resumes from the latest
211
- completed ancestor and lets the next user turn create a branch. `/export`
212
- writes only the currently selected path.
206
+ `/timeline` shows completed, failed, and interrupted turns in the conversation
207
+ tree. Selecting an older turn changes only the in-memory path: it creates and
208
+ saves a branch only after the next real user message. Cancelling the picker or
209
+ exiting before that message leaves the durable head unchanged. A failed turn
210
+ keeps the same partial evidence and outcome in the live transcript, export, and
211
+ resume, while the next model receives a neutral failure boundary instead of
212
+ incomplete streamed text. Historical tools are displayed but never executed.
213
+ If a process stops abruptly inside a tool loop, Jecode resumes from the latest
214
+ safe ancestor and lets the next user turn create a branch. `/export` writes
215
+ only the currently selected path.
213
216
 
214
217
  When model-facing context approaches the selected model's usable capacity,
215
218
  Jecode asks the provider for a bounded summary of the older prefix and keeps
@@ -289,8 +292,9 @@ untrusted data.
289
292
  - Provider handshakes and idle response bodies have finite deadlines. Only
290
293
  idempotent catalogue reads retry; generation requests are never replayed.
291
294
  - Model, terminal, and filesystem input are bounded before use.
292
- - Session files are versioned, size-bounded, atomically checkpointed, and
293
- treated as untrusted when loaded. A live lease prevents concurrent resume.
295
+ - Session files are versioned, symmetrically size-bounded before write and
296
+ after read, atomically checkpointed, and treated as untrusted when loaded. A
297
+ live lease prevents concurrent resume.
294
298
 
295
299
  `run_command` is not an operating-system sandbox. An approved command can still
296
300
  access files and account resources available to the current user. Review
@@ -62,6 +62,7 @@ export async function compactSession(session, options = {}) {
62
62
  messages: active.messages,
63
63
  blocks: active.blocks,
64
64
  context: result.anchor,
65
+ ...(active.failure === undefined ? {} : { failure: active.failure }),
65
66
  }, active.settlement);
66
67
  await session.persistence?.checkpoint(next);
67
68
  session.conversation = next;
@@ -5,6 +5,7 @@
5
5
  // project an older prefix before it is sent to a provider. Provider traffic
6
6
  // and live screen blocks remain prospective until a consistent checkpoint.
7
7
  import { projectContext, validContextAnchor } from "./context/projection.js";
8
+ import { assertPersistableNode } from "./sessions/codec.js";
8
9
  export const CONVERSATION_LIMITS = Object.freeze({
9
10
  nodes: 1_024,
10
11
  messageCodeUnits: 8_388_608,
@@ -37,6 +38,7 @@ export class ConversationTree {
37
38
  messages: node.messages,
38
39
  blocks: node.blocks,
39
40
  ...(node.context === undefined ? {} : { context: node.context }),
41
+ ...(node.failure === undefined ? {} : { failure: node.failure }),
40
42
  }, node.settlement);
41
43
  const restored = tree.activeNode;
42
44
  if (restored === undefined || restored.id !== node.id) {
@@ -92,6 +94,19 @@ export class ConversationTree {
92
94
  }
93
95
  return undefined;
94
96
  }
97
+ /** Select the newest turn that is safe to continue after a restart. */
98
+ latestResumable() {
99
+ let id = this.#activeNodeId;
100
+ while (id !== 0) {
101
+ const node = this.node(id);
102
+ if (node === undefined)
103
+ throw new Error("conversation path is incomplete");
104
+ if (node.settlement !== "checkpointed")
105
+ return this.select(id);
106
+ id = node.parentId;
107
+ }
108
+ return undefined;
109
+ }
95
110
  get history() {
96
111
  return this.#path().flatMap((node) => clone(node.messages));
97
112
  }
@@ -99,7 +114,12 @@ export class ConversationTree {
99
114
  return projectContext(this.#path());
100
115
  }
101
116
  get transcript() {
102
- return this.#path().flatMap((node) => clone(node.blocks));
117
+ return this.#path().flatMap((node) => [
118
+ ...clone(node.blocks),
119
+ ...(node.failure === undefined
120
+ ? []
121
+ : [{ kind: "notice", text: node.failure.text, tone: node.failure.tone }]),
122
+ ]);
103
123
  }
104
124
  #append(draft, settlement) {
105
125
  if (this.#nodes.length >= CONVERSATION_LIMITS.nodes) {
@@ -115,8 +135,10 @@ export class ConversationTree {
115
135
  messages: draft.messages,
116
136
  blocks: settledBlocks(draft.blocks),
117
137
  ...(draft.context === undefined ? {} : { context: draft.context }),
138
+ ...(draft.failure === undefined ? {} : { failure: draft.failure }),
118
139
  });
119
140
  assertTurn(node);
141
+ assertPersistableNode(node);
120
142
  const nodes = [...this.#nodes, node];
121
143
  assertBounds(nodes);
122
144
  return new ConversationTree(nodes, node.id);
@@ -135,8 +157,10 @@ export class ConversationTree {
135
157
  messages: draft.messages,
136
158
  blocks: settledBlocks(draft.blocks),
137
159
  context: draft.context ?? current.context,
160
+ failure: draft.failure,
138
161
  });
139
162
  assertTurn(node);
163
+ assertPersistableNode(node);
140
164
  const nodes = [...this.#nodes];
141
165
  nodes[id - 1] = node;
142
166
  assertBounds(nodes);
@@ -163,6 +187,7 @@ function ownedNode(node) {
163
187
  messages: Object.freeze(clone(node.messages)),
164
188
  blocks: Object.freeze(clone(node.blocks)),
165
189
  ...(node.context === undefined ? {} : { context: Object.freeze({ ...node.context }) }),
190
+ ...(node.failure === undefined ? {} : { failure: Object.freeze({ ...node.failure }) }),
166
191
  });
167
192
  }
168
193
  function settledBlocks(blocks) {
@@ -174,6 +199,8 @@ function settledBlocks(blocks) {
174
199
  return [settled];
175
200
  }
176
201
  if (block.kind === "tool") {
202
+ if (block.tone === "pending")
203
+ return [];
177
204
  const { startedAt: _startedAt, expanded: _expanded, ...settled } = block;
178
205
  return [settled];
179
206
  }
@@ -185,16 +212,26 @@ function assertTurn(node) {
185
212
  !validNodeId(node.parentId) || node.parentId >= node.id ||
186
213
  !Number.isSafeInteger(node.revision) || node.revision < 1 ||
187
214
  node.createdAt.length === 0 || node.createdAt.length > 64 ||
188
- (node.settlement !== "checkpointed" && node.settlement !== "completed") ||
215
+ !validSettlement(node.settlement) ||
189
216
  node.messages.length < 2 || node.messages[0]?.role !== "user" ||
190
217
  node.identity.providerId.length === 0 || node.identity.providerId.length > 128 ||
191
218
  node.identity.model.length === 0 || node.identity.model.length > 512 ||
192
219
  node.identity.effort.length === 0 || node.identity.effort.length > 32)
193
220
  throw new Error("turn checkpoint is invalid");
194
- if (node.settlement === "completed" && node.messages.at(-1)?.role !== "assistant") {
195
- throw new Error("a completed turn must end with an assistant message");
221
+ if (node.settlement !== "checkpointed" && node.messages.at(-1)?.role !== "assistant") {
222
+ throw new Error("a resumable turn must end with an assistant message");
223
+ }
224
+ const failed = node.settlement === "failed" || node.settlement === "interrupted";
225
+ if (failed !== (node.failure !== undefined) ||
226
+ (node.settlement === "failed" && node.failure?.tone !== "error") ||
227
+ (node.settlement === "interrupted" && node.failure?.tone !== "warn")) {
228
+ throw new Error("turn failure state is invalid");
196
229
  }
197
230
  }
231
+ function validSettlement(value) {
232
+ return value === "checkpointed" || value === "completed" ||
233
+ value === "failed" || value === "interrupted";
234
+ }
198
235
  function assertBounds(nodes) {
199
236
  let messageCodeUnits = 0;
200
237
  let transcriptCodeUnits = 0;
@@ -1,17 +1,21 @@
1
1
  // Strict codecs for session files. Disk is an untrusted boundary even when
2
2
  // the directory is owner-only: every value is bounded and re-owned before it
3
3
  // can become conversation or provider input.
4
+ import { Buffer } from "node:buffer";
4
5
  import { CONTEXT_LIMITS } from "../context/projection.js";
5
- export const SESSION_SCHEMA = 2;
6
+ export const SESSION_SCHEMA = 3;
6
7
  export const SESSION_FILE_LIMITS = Object.freeze({
7
8
  text: 1_048_576,
9
+ metadataBytes: 64 * 1_024,
10
+ nodeBytes: 20 * 1_024 * 1_024,
8
11
  jsonDepth: 24,
9
12
  jsonNodes: 32_768,
10
13
  blocks: 8_192,
11
14
  details: 8_192,
12
15
  });
13
16
  export function encodeMeta(meta) {
14
- return line(meta);
17
+ decodeMeta(meta);
18
+ return boundedLine(meta, SESSION_FILE_LIMITS.metadataBytes);
15
19
  }
16
20
  export function decodeMeta(value) {
17
21
  if (!record(value) || !keys(value, "createdAt,id,version,workspaceDigest,workspaceRoot")) {
@@ -32,7 +36,8 @@ export function decodeMeta(value) {
32
36
  });
33
37
  }
34
38
  export function encodeHead(head) {
35
- return line(head);
39
+ decodeHead(head);
40
+ return boundedLine(head, SESSION_FILE_LIMITS.metadataBytes);
36
41
  }
37
42
  export function decodeHead(value) {
38
43
  if (!record(value) || !keys(value, "nodeId,parentId,revision,sequence,updatedAt,version")) {
@@ -55,7 +60,16 @@ export function decodeHead(value) {
55
60
  });
56
61
  }
57
62
  export function encodeNode(node, sequence, updatedAt) {
58
- return line({
63
+ const envelope = nodeEnvelope(node, sequence, updatedAt);
64
+ decodeNode(envelope);
65
+ return boundedLine(envelope, SESSION_FILE_LIMITS.nodeBytes);
66
+ }
67
+ /** Enforce the exact current-disk boundary before a turn enters the tree. */
68
+ export function assertPersistableNode(node) {
69
+ encodeNode(node, 1, node.createdAt);
70
+ }
71
+ function nodeEnvelope(node, sequence, updatedAt) {
72
+ return {
59
73
  version: SESSION_SCHEMA,
60
74
  sequence,
61
75
  updatedAt,
@@ -69,8 +83,9 @@ export function encodeNode(node, sequence, updatedAt) {
69
83
  messages: node.messages.map(messageRecord),
70
84
  blocks: node.blocks.flatMap(blockRecord),
71
85
  context: node.context ?? null,
86
+ failure: node.failure ?? null,
72
87
  },
73
- });
88
+ };
74
89
  }
75
90
  export function decodeNode(value) {
76
91
  if (!record(value) || !keys(value, "node,sequence,updatedAt,version"))
@@ -82,7 +97,9 @@ export function decodeNode(value) {
82
97
  const raw = value["node"];
83
98
  const nodeKeys = version === 1
84
99
  ? "blocks,createdAt,id,identity,messages,parentId,revision,settlement"
85
- : "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement";
100
+ : version === 2
101
+ ? "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement"
102
+ : "blocks,context,createdAt,failure,id,identity,messages,parentId,revision,settlement";
86
103
  if (!record(raw) || !keys(raw, nodeKeys)) {
87
104
  throw invalid();
88
105
  }
@@ -93,7 +110,7 @@ export function decodeNode(value) {
93
110
  !integer(raw["parentId"], 0) ||
94
111
  !integer(raw["revision"], 1) ||
95
112
  !timestamp(raw["createdAt"]) ||
96
- (raw["settlement"] !== "checkpointed" && raw["settlement"] !== "completed") ||
113
+ !settlement(raw["settlement"], version) ||
97
114
  !record(identity) || !keys(identity, "effort,model,providerId") ||
98
115
  !bounded(identity["providerId"], 128) || !bounded(identity["model"], 512) ||
99
116
  !bounded(identity["effort"], 32) ||
@@ -103,6 +120,9 @@ export function decodeNode(value) {
103
120
  const context = version === 1
104
121
  ? undefined
105
122
  : contextFromRecord(raw["context"], raw["id"], messages.length);
123
+ const failure = version < 3
124
+ ? undefined
125
+ : failureFromRecord(raw["failure"], raw["settlement"]);
106
126
  const node = Object.freeze({
107
127
  id: raw["id"],
108
128
  parentId: raw["parentId"],
@@ -117,9 +137,25 @@ export function decodeNode(value) {
117
137
  messages: Object.freeze(messages.map(messageFromRecord)),
118
138
  blocks: Object.freeze(blocks.map(blockFromRecord)),
119
139
  ...(context === undefined ? {} : { context: Object.freeze(context) }),
140
+ ...(failure === undefined ? {} : { failure: Object.freeze(failure) }),
120
141
  });
121
142
  return Object.freeze({ sequence: value["sequence"], updatedAt: value["updatedAt"], node });
122
143
  }
144
+ function failureFromRecord(value, settlement) {
145
+ const failed = settlement === "failed" || settlement === "interrupted";
146
+ if (value === null) {
147
+ if (failed)
148
+ throw invalid();
149
+ return undefined;
150
+ }
151
+ if (!failed || !record(value) || !keys(value, "text,tone"))
152
+ throw invalid();
153
+ if (!bounded(value["text"]) ||
154
+ (settlement === "failed" && value["tone"] !== "error") ||
155
+ (settlement === "interrupted" && value["tone"] !== "warn"))
156
+ throw invalid();
157
+ return { text: value["text"], tone: value["tone"] };
158
+ }
123
159
  function contextFromRecord(value, ownerId, ownerMessages) {
124
160
  if (value === null)
125
161
  return undefined;
@@ -340,6 +376,12 @@ function jsonValue(value, budget, depth) {
340
376
  function line(value) {
341
377
  return `${JSON.stringify(value, null, 2)}\n`;
342
378
  }
379
+ function boundedLine(value, maxBytes) {
380
+ const encoded = line(value);
381
+ if (Buffer.byteLength(encoded, "utf8") > maxBytes)
382
+ throw invalid();
383
+ return encoded;
384
+ }
343
385
  function keys(value, expected) {
344
386
  return Object.keys(value).sort().join(",") === expected;
345
387
  }
@@ -356,7 +398,11 @@ function integer(value, minimum) {
356
398
  return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
357
399
  }
358
400
  function schema(value) {
359
- return value === 1 || value === SESSION_SCHEMA;
401
+ return value === 1 || value === 2 || value === SESSION_SCHEMA;
402
+ }
403
+ function settlement(value, version) {
404
+ return value === "checkpointed" || value === "completed" ||
405
+ (version >= 3 && (value === "failed" || value === "interrupted"));
360
406
  }
361
407
  function nullableInteger(value, minimum) {
362
408
  return value === null || integer(value, minimum);
@@ -21,9 +21,9 @@ export class SessionPersistence {
21
21
  const lease = await store.claim(id);
22
22
  try {
23
23
  const snapshot = await store.load(id);
24
- const conversation = snapshot.conversation.latestCompleted();
24
+ const conversation = snapshot.conversation.latestResumable();
25
25
  if (conversation === undefined)
26
- throw new Error("session has no completed turn to resume");
26
+ throw new Error("session has no resumable turn");
27
27
  return Object.freeze({
28
28
  conversation,
29
29
  persistence: new SessionPersistence(store, id, lease),
@@ -4,17 +4,17 @@
4
4
  // after that node is durable, so a crash leaves either the prior checkpoint or
5
5
  // one strictly recoverable mutation -- never an ambiguous partial history.
6
6
  import { createHash, randomUUID } from "node:crypto";
7
- import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
7
+ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
10
  import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
11
  import { userDataPath } from "../user-data.js";
12
- import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_SCHEMA, } from "./codec.js";
12
+ import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
13
13
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
14
14
  const DIRECTORY_MODE = 0o700;
15
15
  const FILE_MODE = 0o600;
16
- const MAX_CATALOG_SCAN = 128;
17
- const MAX_JSON_BYTES = 20 * 1024 * 1024;
16
+ const MAX_CATALOG_ENTRIES = 4_096;
17
+ const CATALOG_READ_CONCURRENCY = 8;
18
18
  const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
19
19
  const NODE_NAME = /^(\d{6})\.json$/;
20
20
  const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
@@ -37,45 +37,45 @@ export class DurableSessionStore {
37
37
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
38
38
  throw new Error("session catalogue limit is invalid");
39
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);
40
+ const names = await catalogNames(this.#bucket);
46
41
  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
- }
42
+ for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
43
+ const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
44
+ .map(async (id) => {
45
+ try {
46
+ const snapshot = await this.load(id);
47
+ const conversation = snapshot.conversation.latestResumable();
48
+ if (conversation === undefined)
49
+ return undefined;
50
+ return {
51
+ id,
52
+ createdAt: snapshot.meta.createdAt,
53
+ updatedAt: snapshot.head.updatedAt,
54
+ turns: selectedTurnCount(conversation),
55
+ preview: firstUserText(conversation),
56
+ active: await this.#leaseIsActive(id),
57
+ };
58
+ }
59
+ catch {
60
+ // Corrupt or foreign data never becomes a resume candidate.
61
+ return undefined;
62
+ }
63
+ }));
64
+ catalog.push(...batch.filter((entry) => entry !== undefined));
65
65
  }
66
66
  return catalog
67
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
67
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
68
68
  .slice(0, limit);
69
69
  }
70
70
  async load(id) {
71
71
  assertSessionId(id);
72
72
  const directory = this.#sessionDirectory(id);
73
73
  await assertDirectory(directory);
74
- const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), 64 * 1024));
74
+ const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
75
75
  if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
76
76
  workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
77
77
  throw new Error("session belongs to a different workspace");
78
- let head = decodeHead(await readJson(path.join(directory, "head.json"), 64 * 1024));
78
+ let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
79
79
  const stored = await readNodes(path.join(directory, "nodes"));
80
80
  const ahead = stored.filter((entry) => entry.sequence > head.sequence);
81
81
  if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
@@ -242,6 +242,28 @@ export class DurableSessionStore {
242
242
  return owner !== undefined && pidIsAlive(owner.pid);
243
243
  }
244
244
  }
245
+ async function catalogNames(directory) {
246
+ try {
247
+ await assertDirectory(directory);
248
+ }
249
+ catch (error) {
250
+ if (error.code === "ENOENT")
251
+ return [];
252
+ throw error;
253
+ }
254
+ const names = [];
255
+ let entries = 0;
256
+ const handle = await opendir(directory);
257
+ for await (const entry of handle) {
258
+ entries++;
259
+ if (entries > MAX_CATALOG_ENTRIES) {
260
+ throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
261
+ }
262
+ if (entry.isDirectory() && SESSION_NAME.test(entry.name))
263
+ names.push(entry.name);
264
+ }
265
+ return names.sort((left, right) => right.localeCompare(left));
266
+ }
245
267
  function assertSharedNodes(previous, next, replacedId) {
246
268
  for (const node of previous.nodes) {
247
269
  if (node.id === replacedId)
@@ -278,7 +300,7 @@ async function readNodes(directory) {
278
300
  const id = Number(NODE_NAME.exec(name)?.[1]);
279
301
  if (id !== index + 1)
280
302
  throw new Error("session conversation nodes are not contiguous");
281
- const decoded = decodeNode(await readJson(path.join(directory, name), MAX_JSON_BYTES));
303
+ const decoded = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
282
304
  if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
283
305
  throw new Error("session conversation node identity is invalid");
284
306
  }
package/dist/timeline.js CHANGED
@@ -7,7 +7,7 @@ import { usageFromHistory } from "./usage.js";
7
7
  import { heading } from "./tui/picker.js";
8
8
  export function timelinePicker(conversation, palette) {
9
9
  const entries = timelineEntries(conversation);
10
- const selectedId = conversation.latestCompleted()?.activeNodeId ?? 0;
10
+ const selectedId = conversation.latestResumable()?.activeNodeId ?? 0;
11
11
  const index = Math.max(0, entries.findIndex((entry) => entry.node.id === selectedId));
12
12
  return Object.freeze({
13
13
  picker: {
@@ -18,7 +18,7 @@ export function timelinePicker(conversation, palette) {
18
18
  options: entries.map((entry) => ({
19
19
  label: `${entry.prefix}${preview(entry.node)}`,
20
20
  hint: stamp(entry.node.createdAt),
21
- ...(entry.node.id === selectedId ? { value: "active" } : {}),
21
+ ...timelineValue(entry.node, selectedId),
22
22
  })),
23
23
  index,
24
24
  },
@@ -38,12 +38,12 @@ export async function selectTimeline(session, choose) {
38
38
  return true;
39
39
  }
40
40
  function timelineEntries(conversation) {
41
- const completed = conversation.nodes.filter((node) => node.settlement === "completed");
42
- const completedIds = new Set(completed.map((node) => node.id));
41
+ const resumable = conversation.nodes.filter((node) => node.settlement !== "checkpointed");
42
+ const resumableIds = new Set(resumable.map((node) => node.id));
43
43
  const children = new Map();
44
- for (const node of completed) {
44
+ for (const node of resumable) {
45
45
  let parentId = node.parentId;
46
- while (parentId !== 0 && !completedIds.has(parentId)) {
46
+ while (parentId !== 0 && !resumableIds.has(parentId)) {
47
47
  parentId = conversation.node(parentId)?.parentId ?? 0;
48
48
  }
49
49
  const siblings = children.get(parentId) ?? [];
@@ -70,6 +70,12 @@ function timelineEntries(conversation) {
70
70
  visit(0, []);
71
71
  return entries;
72
72
  }
73
+ function timelineValue(node, selectedId) {
74
+ const state = node.settlement === "completed" ? undefined : node.settlement;
75
+ const active = node.id === selectedId ? "active" : undefined;
76
+ const value = [state, active].filter((part) => part !== undefined).join(" · ");
77
+ return value === "" ? {} : { value };
78
+ }
73
79
  function preview(node) {
74
80
  for (const message of node.messages) {
75
81
  if (message.role !== "user")
@@ -10,6 +10,7 @@ import { saveTranscript } from "../transcript-export.js";
10
10
  import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
11
11
  import { selectTimeline } from "../timeline.js";
12
12
  import { answerAt } from "./approve.js";
13
+ import * as edit from "./editor.js";
13
14
  import { cancel as cancelOpen } from "./overlay.js";
14
15
  import { controllerOptions, turnFailure } from "./session-view.js";
15
16
  import { transcribe } from "./turn.js";
@@ -136,7 +137,7 @@ export function appWorkflows(options) {
136
137
  },
137
138
  usage: (usage) => recordUsage(session.usage, usage),
138
139
  });
139
- const persist = async (checkpoint, settlement) => {
140
+ const persist = async (checkpoint, settlement, failure) => {
140
141
  const next = session.conversation.commit({
141
142
  ...(nodeId === undefined ? {} : { nodeId }),
142
143
  parentId,
@@ -149,6 +150,7 @@ export function appWorkflows(options) {
149
150
  messages: checkpoint.slice(historyStart),
150
151
  blocks: state.blocks.slice(blockStart),
151
152
  ...(context === undefined ? {} : { context }),
153
+ ...(failure === undefined ? {} : { failure }),
152
154
  }, settlement);
153
155
  await session.persistence?.checkpoint(next);
154
156
  session.conversation = next;
@@ -217,18 +219,65 @@ export function appWorkflows(options) {
217
219
  return compacted;
218
220
  };
219
221
  let finishReason;
222
+ let failed;
220
223
  try {
221
224
  await runTurn(history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal, modelHistory);
222
225
  }
223
226
  catch (error) {
224
227
  const interrupted = activity.control.signal.aborted;
225
- finishReason = interrupted ? "interrupted" : "failed";
226
- options.emit(turnFailure(session, error, interrupted));
228
+ const completed = nodeId !== undefined && session.conversation.activeNodeId === nodeId &&
229
+ session.conversation.activeNode?.settlement === "completed";
230
+ if (completed) {
231
+ const notice = turnFailure(session, error, interrupted);
232
+ feedback.show({ text: notice.text, tone: notice.tone, timeoutMs: 6_000 });
233
+ }
234
+ else {
235
+ finishReason = interrupted ? "interrupted" : "failed";
236
+ failed = { error: error, interrupted };
237
+ }
227
238
  }
228
239
  finally {
229
- events.finish(finishReason);
230
- options.finishActivity(activity);
240
+ try {
241
+ events.finish(finishReason);
242
+ if (failed !== undefined) {
243
+ const notice = turnFailure(session, failed.error, failed.interrupted);
244
+ const settlement = failed.interrupted ? "interrupted" : "failed";
245
+ const failure = {
246
+ text: notice.text,
247
+ tone: failed.interrupted ? "warn" : "error",
248
+ };
249
+ options.emit(notice);
250
+ try {
251
+ await persist(closeFailedTurn(history, settlement), settlement, failure);
252
+ }
253
+ catch (error) {
254
+ // A failed persistence boundary cannot remain visible as if it had
255
+ // been saved. Revert to the last durable path and return the input
256
+ // to the composer so the user can retry without losing it.
257
+ options.replaceTranscript();
258
+ state.editor = edit.of(text);
259
+ feedback.show({
260
+ text: error.message,
261
+ tone: "error",
262
+ timeoutMs: 6_000,
263
+ });
264
+ }
265
+ }
266
+ }
267
+ finally {
268
+ options.finishActivity(activity);
269
+ }
231
270
  }
232
271
  }
233
272
  return { command, turn };
234
273
  }
274
+ function closeFailedTurn(history, settlement) {
275
+ const closed = [...history];
276
+ if (closed.at(-1)?.role === "assistant")
277
+ return closed;
278
+ const text = settlement === "interrupted"
279
+ ? "The previous attempt was interrupted by the user before completion."
280
+ : "The previous attempt failed before completion.";
281
+ closed.push({ role: "assistant", content: [{ kind: "text", text }] });
282
+ return closed;
283
+ }
package/dist/tui/app.js CHANGED
@@ -41,6 +41,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
41
41
  let stopResize = () => { };
42
42
  let stopInput = () => { };
43
43
  let failure;
44
+ let activeWorkflow;
44
45
  // Timers outlive the teardown they were scheduled before. Painting after the
45
46
  // terminal has been handed back would write escapes into the user's shell.
46
47
  let live = true;
@@ -141,22 +142,42 @@ export async function runApp(session, transcriptRoot, environment = {}) {
141
142
  if (!live)
142
143
  return;
143
144
  live = false;
144
- stopInput();
145
- stopResize();
145
+ safely(stopInput);
146
+ safely(stopResize);
146
147
  if (spinTimer !== undefined)
147
148
  clearInterval(spinTimer);
148
149
  if (frameTimer !== undefined)
149
150
  clearTimeout(frameTimer);
150
151
  if (escapeTimer !== undefined)
151
152
  clearTimeout(escapeTimer);
152
- feedback.close();
153
- terminal.leave();
153
+ safely(() => feedback.close());
154
+ safely(() => terminal.leave());
154
155
  closed?.();
155
156
  }
157
+ function safely(action) {
158
+ try {
159
+ action();
160
+ }
161
+ catch (error) {
162
+ failure ??= { error };
163
+ }
164
+ }
156
165
  function fail(error) {
157
166
  failure ??= { error };
167
+ state.open = overlay.cancel(state.open);
168
+ state.activity?.control.abort(error);
158
169
  quit();
159
170
  }
171
+ function track(work) {
172
+ const tracked = work
173
+ .catch((error) => fail(error))
174
+ .finally(() => {
175
+ if (activeWorkflow === tracked)
176
+ activeWorkflow = undefined;
177
+ });
178
+ activeWorkflow = tracked;
179
+ return tracked;
180
+ }
160
181
  function requestQuit() {
161
182
  const activity = state.activity;
162
183
  if (activity === undefined) {
@@ -226,7 +247,10 @@ export async function runApp(session, transcriptRoot, environment = {}) {
226
247
  session,
227
248
  state,
228
249
  feedback,
229
- actions,
250
+ actions: {
251
+ command: (text) => track(actions.command(text)),
252
+ turn: (text) => track(actions.turn(text)),
253
+ },
230
254
  live: () => live,
231
255
  quit,
232
256
  requestQuit,
@@ -236,7 +260,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
236
260
  });
237
261
  const resumeAtLaunch = session.resume === undefined
238
262
  ? undefined
239
- : openResumedSession(session.resume);
263
+ : track(openResumedSession(session.resume));
240
264
  async function openResumedSession(launch) {
241
265
  while (live) {
242
266
  const index = await new Promise((resolve) => {
@@ -305,6 +329,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
305
329
  finally {
306
330
  try {
307
331
  quit();
332
+ await activeWorkflow;
308
333
  }
309
334
  finally {
310
335
  await session.persistence?.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {