@giovannijecha/jecode 0.7.3 → 0.8.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 (54) hide show
  1. package/README.md +104 -29
  2. package/dist/atomic.js +14 -0
  3. package/dist/batch.js +5 -2
  4. package/dist/cli-info.js +1 -1
  5. package/dist/config.js +4 -1
  6. package/dist/context/policy.js +14 -7
  7. package/dist/controller-request.js +1 -1
  8. package/dist/controller.js +39 -15
  9. package/dist/duration.js +8 -0
  10. package/dist/oauth-http.js +2 -1
  11. package/dist/openai-account.js +12 -8
  12. package/dist/openai-oauth-callback.js +2 -1
  13. package/dist/providers/anthropic-stream.js +8 -0
  14. package/dist/providers/anthropic-wire.js +22 -10
  15. package/dist/providers/http.js +4 -3
  16. package/dist/providers/ollama-stream.js +7 -0
  17. package/dist/providers/ollama-wire.js +11 -3
  18. package/dist/providers/openai-codex.js +0 -2
  19. package/dist/providers/openai-stream.js +57 -2
  20. package/dist/providers/openai-wire.js +27 -12
  21. package/dist/providers/sse.js +115 -38
  22. package/dist/sessions/codec.js +13 -5
  23. package/dist/sessions/store.js +2 -1
  24. package/dist/settings-command.js +0 -5
  25. package/dist/settings.js +0 -2
  26. package/dist/steering.js +58 -0
  27. package/dist/text-boundary.js +47 -0
  28. package/dist/timeline.js +2 -1
  29. package/dist/tools/args.js +1 -1
  30. package/dist/tools/fs.js +5 -0
  31. package/dist/tools/text-boundary.js +1 -31
  32. package/dist/transcript.js +6 -1
  33. package/dist/tui/activity.js +20 -5
  34. package/dist/tui/app-input.js +31 -6
  35. package/dist/tui/app-workflows.js +57 -14
  36. package/dist/tui/app.js +18 -3
  37. package/dist/tui/blocks.js +7 -2
  38. package/dist/tui/components/messages.js +20 -17
  39. package/dist/tui/components/misc.js +7 -6
  40. package/dist/tui/components/status.js +12 -1
  41. package/dist/tui/components/tool.js +152 -50
  42. package/dist/tui/help.js +1 -1
  43. package/dist/tui/motion.js +32 -0
  44. package/dist/tui/session-view.js +3 -2
  45. package/dist/tui/transcript-grammar.js +25 -0
  46. package/dist/tui/transcript-view.js +132 -7
  47. package/dist/tui/turn.js +78 -39
  48. package/dist/tui/view.js +4 -2
  49. package/dist/ui/diff.js +51 -16
  50. package/dist/ui/render.js +17 -27
  51. package/dist/ui/theme.js +19 -18
  52. package/dist/ui/width.js +31 -22
  53. package/dist/usage.js +5 -1
  54. package/package.json +1 -1
@@ -44,27 +44,39 @@ export function stopNotice(data) {
44
44
  export function fromWireResponse(data) {
45
45
  const raw = Array.isArray(data.content) ? data.content : [];
46
46
  const content = [];
47
+ let suppressedToolCall = false;
47
48
  for (const item of raw) {
48
49
  const block = item;
49
50
  if (block.type === "text" && typeof block.text === "string") {
50
51
  content.push({ kind: "text", text: block.text });
51
52
  }
52
- else if (block.type === "tool_use" &&
53
- typeof block.id === "string" &&
54
- typeof block.name === "string") {
55
- content.push({
56
- kind: "tool_call",
57
- id: block.id,
58
- name: block.name,
59
- input: (block.input ?? {}),
60
- });
53
+ else if (block.type === "tool_use") {
54
+ if (data.stop_reason === "tool_use" &&
55
+ typeof block.id === "string" &&
56
+ typeof block.name === "string") {
57
+ content.push({
58
+ kind: "tool_call",
59
+ id: block.id,
60
+ name: block.name,
61
+ input: (block.input ?? {}),
62
+ });
63
+ }
64
+ else {
65
+ suppressedToolCall = true;
66
+ }
61
67
  }
62
68
  // thinking / redacted_thinking and anything future: carried in `raw` only.
63
69
  }
64
70
  const notice = stopNotice(data);
65
71
  if (notice !== undefined)
66
72
  content.push({ kind: "text", text: notice });
67
- return { role: "assistant", content, raw, rawFrom: "anthropic", usage: normalizeUsage(data) };
73
+ const retainRaw = data.stop_reason !== "max_tokens" && !suppressedToolCall;
74
+ return {
75
+ role: "assistant",
76
+ content,
77
+ ...(retainRaw ? { raw, rawFrom: "anthropic" } : {}),
78
+ usage: normalizeUsage(data),
79
+ };
68
80
  }
69
81
  function normalizeUsage(data) {
70
82
  const usage = data.usage;
@@ -1,6 +1,7 @@
1
1
  // The entire HTTP layer: one bounded request, then either a JSON body or an
2
2
  // event stream. Only idempotent reads retry. Once a POST starts or response
3
3
  // bytes flow, a failure is surfaced rather than silently replayed.
4
+ import { leadingText } from "../text-boundary.js";
4
5
  import { readSseJson } from "./sse.js";
5
6
  import { sseStreamCharacterLimit } from "./stream-limits.js";
6
7
  const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
@@ -31,7 +32,7 @@ async function asJson(url, res) {
31
32
  return JSON.parse(text);
32
33
  }
33
34
  catch {
34
- throw httpError(`${url} returned non-JSON`, res.status, text.slice(0, 500));
35
+ throw httpError(`${url} returned non-JSON`, res.status, leadingText(text, 500));
35
36
  }
36
37
  }
37
38
  export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
@@ -107,13 +108,13 @@ async function boundedText(url, res, max) {
107
108
  if (done) {
108
109
  text += decoder.decode();
109
110
  return text.length > max
110
- ? { text: text.slice(0, max), truncated: true }
111
+ ? { text: leadingText(text, max), truncated: true }
111
112
  : { text, truncated: false };
112
113
  }
113
114
  text += decoder.decode(value, { stream: true });
114
115
  if (text.length > max) {
115
116
  await reader.cancel().catch(() => undefined);
116
- return { text: text.slice(0, max), truncated: true };
117
+ return { text: leadingText(text, max), truncated: true };
117
118
  }
118
119
  }
119
120
  }
@@ -6,6 +6,7 @@
6
6
  import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
7
7
  export async function assembleOllama(events, onStream) {
8
8
  const calls = new Map();
9
+ const announcedTools = new Map();
9
10
  let toolArgumentChars = 0;
10
11
  let content = "";
11
12
  let reasoning = "";
@@ -53,6 +54,12 @@ export async function assembleOllama(events, onStream) {
53
54
  call.args += part.function.arguments;
54
55
  }
55
56
  calls.set(index, call);
57
+ const name = call.name === "" ? undefined : call.name;
58
+ if (!announcedTools.has(index) ||
59
+ (announcedTools.get(index) === undefined && name !== undefined)) {
60
+ announcedTools.set(index, name);
61
+ onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
62
+ }
56
63
  }
57
64
  }
58
65
  if (finishReason === undefined) {
@@ -60,10 +60,18 @@ export function fromWireReply(reply) {
60
60
  const content = [];
61
61
  if (reply.content !== "")
62
62
  content.push({ kind: "text", text: reply.content });
63
- for (const call of reply.toolCalls) {
64
- content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
63
+ const acceptsToolCalls = reply.finishReason === "tool_calls";
64
+ if (acceptsToolCalls) {
65
+ for (const call of reply.toolCalls) {
66
+ content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
67
+ }
65
68
  }
66
- const raw = reply.reasoning === "" ? undefined : { reasoning: reply.reasoning };
69
+ const notice = stopNotice(reply);
70
+ if (notice !== undefined)
71
+ content.push({ kind: "text", text: notice });
72
+ const suppressedToolCall = reply.toolCalls.length > 0 && !acceptsToolCalls;
73
+ const retainRaw = reply.finishReason !== "length" && !suppressedToolCall;
74
+ const raw = !retainRaw || reply.reasoning === "" ? undefined : { reasoning: reply.reasoning };
67
75
  return {
68
76
  role: "assistant",
69
77
  content,
@@ -152,8 +152,6 @@ function modelContextWindow(entry) {
152
152
  return undefined;
153
153
  const percent = percentage(entry["effective_context_window_percent"]) ?? 95;
154
154
  const tokens = Math.floor(resolved * percent / 100);
155
- if (!validTokenCount(tokens))
156
- return undefined;
157
155
  const automatic = Math.floor(resolved * 9 / 10);
158
156
  const advertised = tokenCount(entry["auto_compact_token_limit"]);
159
157
  return Object.freeze({
@@ -6,6 +6,7 @@
6
6
  // those streamed items remain the fallback when the final envelope is empty.
7
7
  export async function assembleOpenAI(events, onStream) {
8
8
  const items = [];
9
+ const announcedTools = { identities: new Set(), anonymous: false };
9
10
  let refusal = false;
10
11
  for await (const raw of events) {
11
12
  const event = raw;
@@ -24,14 +25,31 @@ export async function assembleOpenAI(events, onStream) {
24
25
  if (typeof event.delta === "string")
25
26
  onStream?.({ kind: "thinking", text: event.delta });
26
27
  break;
28
+ case "response.output_item.added":
29
+ if (isFunctionCall(event.item)) {
30
+ announceTool(event, event.item, announcedTools, onStream);
31
+ }
32
+ break;
33
+ case "response.function_call_arguments.delta":
34
+ case "response.function_call_arguments.done":
35
+ announceTool(event, undefined, announcedTools, onStream);
36
+ break;
27
37
  case "response.output_item.done":
28
- if (event.item !== undefined)
38
+ if (event.item !== undefined) {
39
+ if (isFunctionCall(event.item)) {
40
+ announceTool(event, event.item, announcedTools, onStream);
41
+ }
29
42
  items.push(event.item);
43
+ }
30
44
  break;
31
45
  case "response.done":
32
46
  case "response.completed":
47
+ return withDefaultStatus(reconcileOutput(event.response, items), "completed");
33
48
  case "response.incomplete":
34
- return reconcileOutput(event.response, items);
49
+ return {
50
+ ...reconcileOutput(event.response, items),
51
+ status: "incomplete",
52
+ };
35
53
  case "response.failed": {
36
54
  const response = event.response;
37
55
  throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
@@ -44,9 +62,46 @@ export async function assembleOpenAI(events, onStream) {
44
62
  }
45
63
  throw new Error("openai stream ended before a terminal response event");
46
64
  }
65
+ function isFunctionCall(item) {
66
+ return typeof item === "object" && item !== null &&
67
+ item["type"] === "function_call";
68
+ }
69
+ function announceTool(event, item, announced, onStream) {
70
+ const identities = toolIdentities(event, item);
71
+ if (identities.length === 0) {
72
+ if (announced.anonymous)
73
+ return;
74
+ announced.anonymous = true;
75
+ }
76
+ else {
77
+ const duplicate = identities.some((identity) => announced.identities.has(identity));
78
+ for (const identity of identities)
79
+ announced.identities.add(identity);
80
+ if (duplicate)
81
+ return;
82
+ }
83
+ const rawName = item?.name ?? event.name;
84
+ const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
85
+ onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
86
+ }
87
+ function toolIdentities(event, item) {
88
+ const identities = [];
89
+ const itemId = event.item_id ?? item?.id;
90
+ if (typeof itemId === "string" && itemId !== "")
91
+ identities.push(`item:${itemId}`);
92
+ if (typeof event.output_index === "number")
93
+ identities.push(`output:${event.output_index}`);
94
+ if (typeof item?.call_id === "string" && item.call_id !== "") {
95
+ identities.push(`call:${item.call_id}`);
96
+ }
97
+ return identities;
98
+ }
47
99
  function reconcileOutput(completed, items) {
48
100
  if (completed === undefined)
49
101
  return { output: items };
50
102
  const finalCount = Array.isArray(completed.output) ? completed.output.length : 0;
51
103
  return items.length > finalCount ? { ...completed, output: items } : completed;
52
104
  }
105
+ function withDefaultStatus(response, status) {
106
+ return response.status === undefined ? { ...response, status } : response;
107
+ }
@@ -40,8 +40,9 @@ export function toWireItems(message, providerId = "openai") {
40
40
  }
41
41
  export function stopNotice(data) {
42
42
  const reason = data.incomplete_details?.reason;
43
- if (reason === undefined)
44
- return undefined;
43
+ if (reason === undefined) {
44
+ return data.status === "incomplete" ? "[incomplete response]" : undefined;
45
+ }
45
46
  return reason === "max_output_tokens"
46
47
  ? "[truncated: hit max_output_tokens — raise --max-tokens]"
47
48
  : `[incomplete: ${reason}]`;
@@ -49,6 +50,7 @@ export function stopNotice(data) {
49
50
  export function fromWireResponse(data, providerId = "openai") {
50
51
  const raw = Array.isArray(data.output) ? data.output : [];
51
52
  const content = [];
53
+ let suppressedFunctionCall = false;
52
54
  for (const entry of raw) {
53
55
  const item = entry;
54
56
  if (item.type === "message" && Array.isArray(item.content)) {
@@ -62,22 +64,35 @@ export function fromWireResponse(data, providerId = "openai") {
62
64
  }
63
65
  }
64
66
  }
65
- else if (item.type === "function_call" &&
66
- typeof item.call_id === "string" &&
67
- typeof item.name === "string") {
68
- content.push({
69
- kind: "tool_call",
70
- id: item.call_id,
71
- name: item.name,
72
- input: parseArguments(item.arguments),
73
- });
67
+ else if (item.type === "function_call") {
68
+ if (data.status === "completed" &&
69
+ (item.status === undefined || item.status === "completed") &&
70
+ typeof item.call_id === "string" &&
71
+ typeof item.name === "string") {
72
+ content.push({
73
+ kind: "tool_call",
74
+ id: item.call_id,
75
+ name: item.name,
76
+ input: parseArguments(item.arguments),
77
+ });
78
+ }
79
+ else {
80
+ suppressedFunctionCall = true;
81
+ }
74
82
  }
75
83
  // reasoning items and anything future: carried in `raw` only.
76
84
  }
77
85
  const notice = stopNotice(data);
78
86
  if (notice !== undefined)
79
87
  content.push({ kind: "text", text: notice });
80
- return { role: "assistant", content, raw, rawFrom: providerId, usage: normalizeUsage(data) };
88
+ return {
89
+ role: "assistant",
90
+ content,
91
+ ...(data.status === "completed" && !suppressedFunctionCall
92
+ ? { raw, rawFrom: providerId }
93
+ : {}),
94
+ usage: normalizeUsage(data),
95
+ };
81
96
  }
82
97
  function normalizeUsage(data) {
83
98
  const usage = data.usage;
@@ -7,37 +7,26 @@ import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
7
7
  export async function* readSseJson(body, maximumChars) {
8
8
  const reader = body.getReader();
9
9
  const decoder = new TextDecoder();
10
- let buffer = "";
10
+ const parser = new SseEventParser();
11
11
  let finished = false;
12
12
  let total = 0;
13
- const append = (text) => {
14
- total = addBounded(total, text.length, maximumChars, "SSE stream");
15
- buffer += text;
16
- };
17
13
  try {
18
14
  for (;;) {
19
15
  const { done, value } = await reader.read();
20
16
  if (done)
21
17
  break;
22
- append(decoder.decode(value, { stream: true }));
23
- for (;;) {
24
- const boundary = findBoundary(buffer);
25
- if (boundary === undefined)
26
- break;
27
- assertEventSize(boundary.start);
28
- const chunk = buffer.slice(0, boundary.start);
29
- buffer = buffer.slice(boundary.end);
30
- const payload = parseData(chunk);
31
- if (payload !== undefined)
32
- yield payload;
33
- }
34
- assertEventSize(buffer.length);
18
+ const text = decoder.decode(value, { stream: true });
19
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
20
+ for (const payload of parser.push(text))
21
+ yield payload;
35
22
  }
36
23
  // A stream that ends without a trailing blank line still owes us its last
37
24
  // event.
38
- append(decoder.decode());
39
- assertEventSize(buffer.length);
40
- const payload = parseData(buffer);
25
+ const text = decoder.decode();
26
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
27
+ for (const payload of parser.push(text))
28
+ yield payload;
29
+ const payload = parser.finish();
41
30
  if (payload !== undefined)
42
31
  yield payload;
43
32
  finished = true;
@@ -48,28 +37,116 @@ export async function* readSseJson(body, maximumChars) {
48
37
  reader.releaseLock();
49
38
  }
50
39
  }
40
+ // Keep fragments in bounded groups. A provider may split one SSE line into
41
+ // hundreds of thousands of tiny chunks; repeatedly flattening the growing line
42
+ // would make parsing quadratic even if boundary scanning itself were linear.
43
+ class TextParts {
44
+ #groups = [];
45
+ #pieces = [];
46
+ #lastCodeUnit = "";
47
+ length = 0;
48
+ append(text) {
49
+ if (text === "")
50
+ return;
51
+ this.#pieces.push(text);
52
+ this.#lastCodeUnit = text.at(-1);
53
+ this.length += text.length;
54
+ if (this.#pieces.length >= 256)
55
+ this.#flush();
56
+ }
57
+ take() {
58
+ this.#flush();
59
+ const text = this.#groups.length === 1 ? this.#groups[0] : this.#groups.join("");
60
+ this.#groups.length = 0;
61
+ this.#lastCodeUnit = "";
62
+ this.length = 0;
63
+ return text;
64
+ }
65
+ endsWithCarriageReturn() {
66
+ return this.#lastCodeUnit === "\r";
67
+ }
68
+ #flush() {
69
+ if (this.#pieces.length === 0)
70
+ return;
71
+ this.#groups.push(this.#pieces.length === 1 ? this.#pieces[0] : this.#pieces.join(""));
72
+ this.#pieces = [];
73
+ }
74
+ }
75
+ export class SseEventParser {
76
+ #line = new TextParts();
77
+ #data = [];
78
+ #eventLength = 0;
79
+ #hasLine = false;
80
+ #pendingTerminatorLength = 0;
81
+ *push(text) {
82
+ let start = 0;
83
+ for (;;) {
84
+ const newline = text.indexOf("\n", start);
85
+ if (newline === -1) {
86
+ this.#line.append(text.slice(start));
87
+ this.#assertPendingLineSize();
88
+ return;
89
+ }
90
+ this.#line.append(text.slice(start, newline));
91
+ const rawLine = this.#line.take();
92
+ const crlf = rawLine.endsWith("\r");
93
+ const line = crlf ? rawLine.slice(0, -1) : rawLine;
94
+ if (line === "") {
95
+ const payload = this.#finishEvent();
96
+ if (payload !== undefined)
97
+ yield payload;
98
+ }
99
+ else {
100
+ this.#appendLine(line, crlf ? 2 : 1);
101
+ }
102
+ start = newline + 1;
103
+ }
104
+ }
105
+ finish() {
106
+ const line = this.#line.take();
107
+ if (line !== "") {
108
+ this.#appendLine(line, 0);
109
+ }
110
+ else if (this.#hasLine) {
111
+ // A single trailing line terminator is part of an unterminated event.
112
+ assertEventSize(this.#eventLength + this.#pendingTerminatorLength);
113
+ }
114
+ return this.#finishEvent();
115
+ }
116
+ #appendLine(line, terminatorLength) {
117
+ const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
118
+ assertEventSize(this.#eventLength + separatorLength + line.length);
119
+ this.#eventLength += separatorLength + line.length;
120
+ this.#hasLine = true;
121
+ this.#pendingTerminatorLength = terminatorLength;
122
+ if (line.startsWith("data:")) {
123
+ this.#data.push(line.slice("data:".length).trimStart());
124
+ }
125
+ }
126
+ #assertPendingLineSize() {
127
+ const separatorLength = this.#hasLine ? this.#pendingTerminatorLength : 0;
128
+ const remaining = MAX_SSE_EVENT_CHARS - this.#eventLength - separatorLength;
129
+ // One trailing CR may turn out to be part of a split CRLF terminator.
130
+ const splitCrlf = this.#line.length === remaining + 1 && this.#line.endsWithCarriageReturn();
131
+ if (this.#line.length > remaining && !splitCrlf) {
132
+ assertEventSize(MAX_SSE_EVENT_CHARS + 1);
133
+ }
134
+ }
135
+ #finishEvent() {
136
+ const data = this.#data.join("\n");
137
+ this.#data = [];
138
+ this.#eventLength = 0;
139
+ this.#hasLine = false;
140
+ this.#pendingTerminatorLength = 0;
141
+ return parseData(data);
142
+ }
143
+ }
51
144
  function assertEventSize(length) {
52
145
  if (length > MAX_SSE_EVENT_CHARS) {
53
146
  throw new Error(`SSE event exceeded ${MAX_SSE_EVENT_CHARS} characters`);
54
147
  }
55
148
  }
56
- // Handles both LF and CRLF framing without normalising the buffer first — a
57
- // normalising pass would have to cope with a \r\n split across two chunks.
58
- function findBoundary(buffer) {
59
- const lf = buffer.indexOf("\n\n");
60
- const crlf = buffer.indexOf("\r\n\r\n");
61
- if (lf === -1 && crlf === -1)
62
- return undefined;
63
- if (crlf !== -1 && (lf === -1 || crlf < lf))
64
- return { start: crlf, end: crlf + 4 };
65
- return { start: lf, end: lf + 2 };
66
- }
67
- function parseData(chunk) {
68
- const data = chunk
69
- .split(/\r?\n/)
70
- .filter((line) => line.startsWith("data:"))
71
- .map((line) => line.slice("data:".length).trimStart())
72
- .join("\n");
149
+ function parseData(data) {
73
150
  if (data === "" || data === "[DONE]")
74
151
  return undefined;
75
152
  try {
@@ -3,7 +3,7 @@
3
3
  // can become conversation or provider input.
4
4
  import { Buffer } from "node:buffer";
5
5
  import { CONTEXT_LIMITS } from "../context/projection.js";
6
- export const SESSION_SCHEMA = 3;
6
+ export const SESSION_SCHEMA = 4;
7
7
  export const SESSION_FILE_LIMITS = Object.freeze({
8
8
  text: 1_048_576,
9
9
  metadataBytes: 64 * 1_024,
@@ -135,7 +135,7 @@ export function decodeNode(value) {
135
135
  effort: identity["effort"],
136
136
  }),
137
137
  messages: Object.freeze(messages.map(messageFromRecord)),
138
- blocks: Object.freeze(blocks.map(blockFromRecord)),
138
+ blocks: Object.freeze(blocks.map((block) => blockFromRecord(block, version))),
139
139
  ...(context === undefined ? {} : { context: Object.freeze(context) }),
140
140
  ...(failure === undefined ? {} : { failure: Object.freeze(failure) }),
141
141
  });
@@ -266,18 +266,23 @@ function blockRecord(block) {
266
266
  right: block.right,
267
267
  tone: block.tone,
268
268
  body: block.body?.map(detailRecord) ?? null,
269
+ durationMs: block.durationMs ?? null,
269
270
  }];
270
271
  }
271
- function blockFromRecord(value) {
272
+ function blockFromRecord(value, version) {
272
273
  if (!record(value) || typeof value["kind"] !== "string")
273
274
  throw invalid();
274
275
  if ((value["kind"] === "user" || value["kind"] === "answer" || value["kind"] === "reasoning") &&
275
276
  keys(value, "kind,text") && boundedText(value["text"]))
276
277
  return { kind: value["kind"], text: value["text"] };
277
- if (value["kind"] === "tool" && keys(value, "body,kind,name,right,target,tone") &&
278
+ const toolKeys = version >= 4
279
+ ? "body,durationMs,kind,name,right,target,tone"
280
+ : "body,kind,name,right,target,tone";
281
+ if (value["kind"] === "tool" && keys(value, toolKeys) &&
278
282
  bounded(value["name"], 256) && boundedText(value["target"]) &&
279
283
  boundedText(value["right"], 1_024) &&
280
284
  (value["tone"] === "ok" || value["tone"] === "fail" || value["tone"] === "deny") &&
285
+ (version < 4 || nullableInteger(value["durationMs"], 0)) &&
281
286
  (value["body"] === null ||
282
287
  (Array.isArray(value["body"]) && value["body"].length <= SESSION_FILE_LIMITS.details))) {
283
288
  const body = value["body"] === null
@@ -290,6 +295,9 @@ function blockFromRecord(value) {
290
295
  right: value["right"],
291
296
  tone: value["tone"],
292
297
  ...(body === undefined ? {} : { body }),
298
+ ...(version < 4 || value["durationMs"] === null
299
+ ? {}
300
+ : { durationMs: value["durationMs"] }),
293
301
  };
294
302
  }
295
303
  throw invalid();
@@ -398,7 +406,7 @@ function integer(value, minimum) {
398
406
  return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
399
407
  }
400
408
  function schema(value) {
401
- return value === 1 || value === 2 || value === SESSION_SCHEMA;
409
+ return value === 1 || value === 2 || value === 3 || value === SESSION_SCHEMA;
402
410
  }
403
411
  function settlement(value, version) {
404
412
  return value === "checkpointed" || value === "completed" ||
@@ -8,6 +8,7 @@ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename
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
+ import { leadingText } from "../text-boundary.js";
11
12
  import { userDataPath } from "../user-data.js";
12
13
  import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
13
14
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
@@ -367,7 +368,7 @@ function firstUserText(conversation) {
367
368
  const text = message.content.find((block) => block.kind === "text")?.text
368
369
  .replace(/\s+/gu, " ").trim();
369
370
  if (text !== undefined && text !== "")
370
- return text.slice(0, 160);
371
+ return leadingText(text, 160);
371
372
  }
372
373
  return "Untitled session";
373
374
  }
@@ -41,9 +41,6 @@ export async function settingsCommand(session, host) {
41
41
  case "maxTokens":
42
42
  await numberSetting(session, host, "maxTokens", "max output tokens");
43
43
  break;
44
- case "maxSteps":
45
- await numberSetting(session, host, "maxSteps", "max tool steps");
46
- break;
47
44
  case "compactionPercent":
48
45
  await compactionSetting(session, host);
49
46
  break;
@@ -81,7 +78,6 @@ function settingsItems(values) {
81
78
  action: "maxTokens",
82
79
  option: { label: "max output tokens", value: String(values.maxTokens) },
83
80
  }]),
84
- { action: "maxSteps", option: { label: "max tool steps", value: String(values.maxSteps) } },
85
81
  {
86
82
  action: "compactionPercent",
87
83
  option: { label: "context compaction", value: `${values.compactionPercent}%` },
@@ -102,7 +98,6 @@ function settingsValues(session) {
102
98
  model: session.model,
103
99
  effort: session.config.effort,
104
100
  ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
105
- maxSteps: session.config.maxSteps,
106
101
  compactionPercent: session.config.compactionPercent,
107
102
  reducedMotion: session.config.reducedMotion,
108
103
  };
package/dist/settings.js CHANGED
@@ -59,7 +59,6 @@ function normalize(value) {
59
59
  const effort = member(value["effort"], EFFORTS);
60
60
  const reducedMotion = typeof value["reducedMotion"] === "boolean" ? value["reducedMotion"] : undefined;
61
61
  const maxTokens = positiveInteger(value["maxTokens"]);
62
- const maxSteps = positiveInteger(value["maxSteps"]);
63
62
  const compactionPercent = percentage(value["compactionPercent"]);
64
63
  return {
65
64
  ...(provider === undefined ? {} : { provider }),
@@ -68,7 +67,6 @@ function normalize(value) {
68
67
  ...(effort === undefined ? {} : { effort }),
69
68
  ...(reducedMotion === undefined ? {} : { reducedMotion }),
70
69
  ...(maxTokens === undefined ? {} : { maxTokens }),
71
- ...(maxSteps === undefined ? {} : { maxSteps }),
72
70
  ...(compactionPercent === undefined ? {} : { compactionPercent }),
73
71
  };
74
72
  }
@@ -0,0 +1,58 @@
1
+ // A bounded cooperative inbox for guidance submitted during an active turn.
2
+ export const MAX_STEERING_MESSAGES = 8;
3
+ export const MAX_STEERING_CODE_UNITS = 32_768;
4
+ /**
5
+ * Build one inbox for one model turn. `drainOrClose` is the atomic completion
6
+ * handshake: pending guidance keeps the turn open; an empty queue closes it
7
+ * before the final checkpoint so late input cannot disappear.
8
+ */
9
+ export function steeringInbox(changed = () => { }) {
10
+ let accepting = true;
11
+ let codeUnits = 0;
12
+ const messages = [];
13
+ const take = () => {
14
+ if (messages.length === 0)
15
+ return [];
16
+ const drained = messages.splice(0);
17
+ codeUnits = 0;
18
+ changed(0, accepting);
19
+ return drained;
20
+ };
21
+ return {
22
+ offer(text) {
23
+ if (!accepting)
24
+ return "closed";
25
+ if (messages.length >= MAX_STEERING_MESSAGES ||
26
+ codeUnits + text.length > MAX_STEERING_CODE_UNITS)
27
+ return "full";
28
+ messages.push(text);
29
+ codeUnits += text.length;
30
+ changed(messages.length, true);
31
+ return "queued";
32
+ },
33
+ drain: take,
34
+ drainOrClose() {
35
+ if (messages.length > 0)
36
+ return { messages: take(), closed: false };
37
+ accepting = false;
38
+ changed(0, false);
39
+ return { messages: [], closed: true };
40
+ },
41
+ close() {
42
+ if (!accepting && messages.length === 0)
43
+ return [];
44
+ accepting = false;
45
+ if (messages.length === 0) {
46
+ changed(0, false);
47
+ return [];
48
+ }
49
+ return take();
50
+ },
51
+ get pending() {
52
+ return messages.length;
53
+ },
54
+ get accepting() {
55
+ return accepting;
56
+ },
57
+ };
58
+ }