@fungi.computer/parakeet 0.1.0 → 0.2.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 (59) hide show
  1. package/README.md +45 -6
  2. package/dist/account-commands.js +3 -11
  3. package/dist/commands/advertised.d.ts +3 -1
  4. package/dist/commands/advertised.js +89 -4
  5. package/dist/commands/interactions.d.ts +22 -7
  6. package/dist/commands/interactions.js +41 -17
  7. package/dist/commands/slash.js +3 -5
  8. package/dist/commands/use-slash-commands.js +2 -2
  9. package/dist/components/AgentHead.d.ts +2 -0
  10. package/dist/components/AgentHead.js +5 -4
  11. package/dist/components/ChatComposer.d.ts +3 -1
  12. package/dist/components/ChatComposer.js +79 -15
  13. package/dist/components/ChatMessage.d.ts +4 -0
  14. package/dist/components/ChatMessage.js +6 -2
  15. package/dist/components/ClientRows.js +2 -2
  16. package/dist/components/InteractionPanel.d.ts +2 -4
  17. package/dist/components/InteractionPanel.js +2 -2
  18. package/dist/components/TimelineItem.d.ts +2 -0
  19. package/dist/components/TimelineItem.js +31 -7
  20. package/dist/conversation-opening.d.ts +47 -0
  21. package/dist/conversation-opening.js +173 -0
  22. package/dist/conversation.d.ts +47 -3
  23. package/dist/conversation.js +584 -86
  24. package/dist/core/author-labels.d.ts +2 -0
  25. package/dist/core/build-timeline.d.ts +4 -2
  26. package/dist/core/build-timeline.js +46 -7
  27. package/dist/core/client-actions.js +6 -3
  28. package/dist/core/client-timeline.d.ts +9 -2
  29. package/dist/core/client-timeline.js +15 -4
  30. package/dist/core/draft-storage.d.ts +63 -0
  31. package/dist/core/draft-storage.js +50 -0
  32. package/dist/core/model-identity.d.ts +2 -0
  33. package/dist/core/model-identity.js +6 -0
  34. package/dist/core/queued-recall.d.ts +35 -0
  35. package/dist/core/queued-recall.js +138 -0
  36. package/dist/core/session-projection.js +17 -0
  37. package/dist/core/session-query.d.ts +54 -11
  38. package/dist/core/session-query.js +38 -12
  39. package/dist/core/tool-groups.d.ts +1 -1
  40. package/dist/core/tool-groups.js +3 -1
  41. package/dist/core/tool-view.js +2 -1
  42. package/dist/core/use-timeline-item-key.d.ts +8 -0
  43. package/dist/core/use-timeline-item-key.js +29 -0
  44. package/dist/headless.d.ts +6 -4
  45. package/dist/headless.js +1 -1
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +1 -1
  48. package/dist/model-commands.js +64 -39
  49. package/dist/parakeet.js +17 -18
  50. package/dist/session-commands.d.ts +0 -9
  51. package/dist/session-commands.js +39 -68
  52. package/dist/session.d.ts +1 -1
  53. package/dist/store/types.d.ts +10 -4
  54. package/dist/styles.css +27 -12
  55. package/docs/api/commands/slash.md +24 -21
  56. package/docs/api/headless.md +375 -232
  57. package/docs/api/index.md +157 -45
  58. package/package.json +16 -10
  59. package/src/styles.css +0 -9
@@ -5,6 +5,8 @@ export type ParakeetAuthor = Readonly<{
5
5
  id: string;
6
6
  /** The name shown beside the line. */
7
7
  name: string;
8
+ /** Another Agent wrote the line; it shows as a compact block, not a bubble. */
9
+ kind?: "agent" | undefined;
8
10
  }>;
9
11
  /**
10
12
  * The host's reading of a message's opaque metadata: the author to show, or
@@ -20,6 +20,8 @@ export type PromptRequestIdOf = (commandId: string) => string | undefined;
20
20
  export type DurableTimelineProjection = TimelineProjection & Readonly<{
21
21
  completedMessageIds: ReadonlySet<string>;
22
22
  commandIds: ReadonlySet<string>;
23
+ requestIds: ReadonlySet<string>;
24
+ positionAnchors: ReadonlyMap<number, string | null>;
23
25
  commandAnchors: ReadonlyMap<string, string>;
24
26
  }>;
25
27
  /**
@@ -29,8 +31,8 @@ export type DurableTimelineProjection = TimelineProjection & Readonly<{
29
31
  * that changed nothing. The model's own context is unaffected by this purely
30
32
  * client-side projection.
31
33
  */
32
- export declare function buildDurableTimeline(messages: readonly SessionMessage[], compactions?: readonly SessionCompactionMarker[], activity?: SessionActivity, authorOf?: ParakeetAuthorOf, requestIdOf?: PromptRequestIdOf, notices?: readonly SessionCompactionNotice[]): DurableTimelineProjection;
34
+ export declare function buildDurableTimeline(messages: readonly SessionMessage[], compactions?: readonly SessionCompactionMarker[], activity?: SessionActivity, authorOf?: ParakeetAuthorOf, requestIdOf?: PromptRequestIdOf, notices?: readonly SessionCompactionNotice[], positions?: readonly number[], shown?: (item: TimelineItem) => boolean): DurableTimelineProjection;
33
35
  /** Project replaceable live work without copying settled history. */
34
- export declare function buildTimelineTail(durable: DurableTimelineProjection, progress: SessionProgress | null, queued: readonly SessionPendingPrompt[], admissions?: readonly LocalPromptAdmission[], authorOf?: ParakeetAuthorOf, requestIdOf?: PromptRequestIdOf): readonly TimelineItem[];
36
+ export declare function buildTimelineTail(durable: DurableTimelineProjection, progress: SessionProgress | null, queued: readonly SessionPendingPrompt[], admissions?: readonly LocalPromptAdmission[], authorOf?: ParakeetAuthorOf): readonly TimelineItem[];
35
37
  /** Overlay live progress and an optimistic prompt over stable durable rows. */
36
38
  export declare function buildTimeline(durable: DurableTimelineProjection, progress: SessionProgress | null, queued: readonly SessionPendingPrompt[], admissions?: readonly LocalPromptAdmission[]): TimelineProjection;
@@ -118,7 +118,7 @@ function appendAssistant(items, toolIndexes, assistant) {
118
118
  items.push(item);
119
119
  }
120
120
  }
121
- function completeTool(items, toolIndexes, value) {
121
+ function completeTool(items, toolIndexes, value, data) {
122
122
  const toolIndex = toolIndexes.get(value.toolCallId);
123
123
  if (toolIndex === undefined)
124
124
  return;
@@ -128,6 +128,7 @@ function completeTool(items, toolIndexes, value) {
128
128
  items[toolIndex] = {
129
129
  ...tool,
130
130
  result: value,
131
+ data,
131
132
  status: value.isError ? "error" : "completed",
132
133
  };
133
134
  }
@@ -138,16 +139,18 @@ function completeTool(items, toolIndexes, value) {
138
139
  * that changed nothing. The model's own context is unaffected by this purely
139
140
  * client-side projection.
140
141
  */
141
- export function buildDurableTimeline(messages, compactions = [], activity, authorOf = noAuthor, requestIdOf = noRequestId, notices = []) {
142
+ export function buildDurableTimeline(messages, compactions = [], activity, authorOf = noAuthor, requestIdOf = noRequestId, notices = [], positions = [], shown = () => true) {
142
143
  const items = [];
143
144
  const toolIndexes = new Map();
144
145
  const commandIds = new Set();
146
+ const itemSequences = new Map();
145
147
  const itemCommands = new Map();
146
148
  let model;
147
149
  let tokens = 0;
148
150
  let cost = 0;
149
151
  for (const source of orderedSources(messages, compactions, notices)) {
150
152
  if (source.kind === "marker") {
153
+ itemSequences.set(source.marker.id, source.sequence);
151
154
  items.push({
152
155
  id: source.marker.id,
153
156
  kind: "compaction_divider",
@@ -158,6 +161,7 @@ export function buildDurableTimeline(messages, compactions = [], activity, autho
158
161
  continue;
159
162
  }
160
163
  if (source.kind === "notice") {
164
+ itemSequences.set(source.notice.id, source.sequence);
161
165
  items.push({
162
166
  id: source.notice.id,
163
167
  kind: "compaction_notice",
@@ -186,7 +190,7 @@ export function buildDurableTimeline(messages, compactions = [], activity, autho
186
190
  author: authorOf(message.meta),
187
191
  origin: message.origin,
188
192
  };
189
- items.push(withRequestId(confirmed, requestIdOf(commandId)));
193
+ items.push(withRequestId(confirmed, message.requestId ?? requestIdOf(commandId)));
190
194
  break;
191
195
  }
192
196
  case "assistant": {
@@ -198,7 +202,7 @@ export function buildDurableTimeline(messages, compactions = [], activity, autho
198
202
  break;
199
203
  }
200
204
  case "toolResult":
201
- completeTool(items, toolIndexes, value);
205
+ completeTool(items, toolIndexes, value, message.data);
202
206
  if (message.commandId !== null) {
203
207
  const commands = itemCommands.get(value.toolCallId) ?? new Set();
204
208
  commands.add(message.commandId);
@@ -206,6 +210,8 @@ export function buildDurableTimeline(messages, compactions = [], activity, autho
206
210
  }
207
211
  break;
208
212
  }
213
+ for (const item of items.slice(firstItem))
214
+ itemSequences.set(item.id, source.sequence);
209
215
  if (message.commandId !== null) {
210
216
  for (const item of items.slice(firstItem))
211
217
  itemCommands.set(item.id, new Set([message.commandId]));
@@ -234,7 +240,18 @@ export function buildDurableTimeline(messages, compactions = [], activity, autho
234
240
  if (active?.kind === "tool")
235
241
  items[toolIndex] = { ...active, status: "running" };
236
242
  }
237
- const grouped = groupToolCalls(items);
243
+ // Later calls must not grow a tool group across a committed event.
244
+ const breakBefore = new Set();
245
+ let previousSequence = 0;
246
+ for (const item of items) {
247
+ const sequence = itemSequences.get(item.id);
248
+ if (sequence === undefined)
249
+ continue;
250
+ if (positions.some((position) => previousSequence <= position && position < sequence))
251
+ breakBefore.add(item.id);
252
+ previousSequence = sequence;
253
+ }
254
+ const grouped = groupToolCalls(items, breakBefore);
238
255
  const commandAnchors = new Map();
239
256
  for (const row of grouped) {
240
257
  if (row.kind === "assistant_message" && !assistantMessageVisible(row))
@@ -244,18 +261,37 @@ export function buildDurableTimeline(messages, compactions = [], activity, autho
244
261
  for (const command of itemCommands.get(member.id) ?? [])
245
262
  commandAnchors.set(command, row.id);
246
263
  }
264
+ const positionAnchors = new Map();
265
+ for (const position of positions) {
266
+ let anchor = null;
267
+ for (const row of grouped) {
268
+ if (!shown(row))
269
+ continue;
270
+ if (row.kind === "assistant_message" && !assistantMessageVisible(row))
271
+ continue;
272
+ const members = row.kind === "tool_group" ? row.tools : [row];
273
+ if (members.some((member) => {
274
+ const sequence = itemSequences.get(member.id);
275
+ return sequence !== undefined && sequence <= position;
276
+ }))
277
+ anchor = row.id;
278
+ }
279
+ positionAnchors.set(position, anchor);
280
+ }
247
281
  const base = {
248
282
  commandAnchors,
283
+ positionAnchors,
249
284
  completedMessageIds: completedMessageIds(messages),
250
285
  items: grouped,
251
286
  commandIds,
287
+ requestIds: new Set(messages.flatMap((message) => message.requestId === undefined ? [] : [message.requestId])),
252
288
  };
253
289
  return model === undefined
254
290
  ? base
255
291
  : { ...base, model, usage: { cost, tokens } };
256
292
  }
257
293
  /** Project replaceable live work without copying settled history. */
258
- export function buildTimelineTail(durable, progress, queued, admissions = [], authorOf = noAuthor, requestIdOf = noRequestId) {
294
+ export function buildTimelineTail(durable, progress, queued, admissions = [], authorOf = noAuthor) {
259
295
  const visibleProgress = progress === null ||
260
296
  durable.completedMessageIds.has(progress.messageId) ||
261
297
  (progress.text === "" && progress.thinking === "")
@@ -280,9 +316,12 @@ export function buildTimelineTail(durable, progress, queued, admissions = [], au
280
316
  origin: prompt.origin,
281
317
  author: authorOf(prompt.meta),
282
318
  };
283
- items.push(withRequestId(waiting, requestIdOf(prompt.commandId)));
319
+ items.push(withRequestId(waiting, prompt.requestId));
284
320
  }
285
321
  for (const admission of admissions) {
322
+ if (durable.requestIds.has(admission.requestId) ||
323
+ queued.some((prompt) => prompt.requestId === admission.requestId))
324
+ continue;
286
325
  if (admission.commandId !== undefined &&
287
326
  (queuedCommandIds.has(admission.commandId) ||
288
327
  durable.commandIds.has(admission.commandId)))
@@ -69,13 +69,16 @@ export function createClientActions(options) {
69
69
  return;
70
70
  publish(intent, { pending: true });
71
71
  const notificationOnly = {
72
+ // Waiting cancellation has no interactive retry surface.
73
+ run: (operation) => operation(),
72
74
  signal: controller.signal,
73
75
  prompt: () => Promise.reject(new WhistleActionError("client_waiting_prompt", "A waiting cancellation cannot request an answer")),
74
76
  notify: (notice) => {
75
- if (retired())
77
+ // A success only confirms the person's own action: no echo.
78
+ if (retired() || notice.type === "success")
76
79
  return;
77
- options.interactions.report({
78
- tone: notice.type === "success" ? "success" : "info",
80
+ options.interactions.toast({
81
+ variant: "info",
79
82
  message: notice.type === "handoff"
80
83
  ? (notice.message ?? notice.url)
81
84
  : notice.message,
@@ -7,8 +7,15 @@ export type PlacedClientCell = Readonly<{
7
7
  /** Producer event, unchanged by host placement. */
8
8
  cell: ClientTimelineCellV1;
9
9
  }>;
10
- /** Page visibility follows real durable command anchors, never event time. */
11
- export declare function placeClientTimeline(entries: readonly ClientContribution[], anchors: ReadonlyMap<string, string>): {
10
+ /** Fixed positions and command anchors follow loaded history, never event time. */
11
+ export declare function placeClientTimeline(entries: readonly ClientContribution[], anchors: ReadonlyMap<string, string>, positions?: ReadonlyMap<number, string | null>): {
12
+ /** Events before the first visible row of the root history page. */
13
+ before: Readonly<{
14
+ /** Namespace of the entry that produced this committed event. */
15
+ entryId: string;
16
+ /** Producer event, unchanged by host placement. */
17
+ cell: ClientTimelineCellV1;
18
+ }>[];
12
19
  /** Loaded durable row IDs mapped to their following committed events. */
13
20
  after: Map<string, Readonly<{
14
21
  /** Namespace of the entry that produced this committed event. */
@@ -31,20 +31,29 @@ function mergeCells(cells) {
31
31
  }
32
32
  return merged;
33
33
  }
34
- /** Page visibility follows real durable command anchors, never event time. */
35
- export function placeClientTimeline(entries, anchors) {
34
+ /** Fixed positions and command anchors follow loaded history, never event time. */
35
+ export function placeClientTimeline(entries, anchors, positions = new Map()) {
36
36
  const anchored = new Map();
37
+ const before = [];
37
38
  const unanchored = [];
38
39
  for (const { entryId, value } of entries) {
39
40
  for (const cell of value.timeline.cells) {
40
41
  const placed = { entryId, cell };
41
- if (cell.commandId === null) {
42
+ if (cell.afterSequence === undefined && cell.commandId === null) {
42
43
  unanchored.push(placed);
43
44
  continue;
44
45
  }
45
- const itemId = anchors.get(cell.commandId);
46
+ const itemId = cell.afterSequence !== undefined
47
+ ? positions.get(cell.afterSequence)
48
+ : cell.commandId === null
49
+ ? undefined
50
+ : anchors.get(cell.commandId);
46
51
  if (itemId === undefined)
47
52
  continue;
53
+ if (itemId === null) {
54
+ before.push(placed);
55
+ continue;
56
+ }
48
57
  const existing = anchored.get(itemId);
49
58
  if (existing === undefined)
50
59
  anchored.set(itemId, [placed]);
@@ -53,6 +62,8 @@ export function placeClientTimeline(entries, anchors) {
53
62
  }
54
63
  }
55
64
  return {
65
+ /** Events before the first visible row of the root history page. */
66
+ before: mergeCells(before),
56
67
  /** Loaded durable row IDs mapped to their following committed events. */
57
68
  after: new Map([...anchored].map(([id, cells]) => [id, mergeCells(cells)])),
58
69
  /** Null-anchor events for the separate Extension changes section. */
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ /** Durable owner identity, independent of the current service endpoint. */
3
+ export type ParakeetDraftIdentity = Readonly<{
4
+ /** Agent that owns the Session. */
5
+ agentId: string;
6
+ /** Durable Session identity, not its transport address. */
7
+ sessionId: string;
8
+ }>;
9
+ /** Hosts must commit a replacement before returning, or throw without changing it. */
10
+ export type ParakeetDraftStorage = Readonly<{
11
+ /** Read the complete journal, or null when no draft has been saved. */
12
+ getItem: (key: string) => string | null;
13
+ /** Atomically replace the journal; throw if it cannot be committed. */
14
+ setItem: (key: string, value: string) => void;
15
+ }>;
16
+ declare const journal: z.ZodObject<{
17
+ draft: z.ZodString;
18
+ images: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodObject<{
19
+ type: z.ZodLiteral<"image">;
20
+ data: z.ZodString;
21
+ mimeType: z.ZodString;
22
+ }, z.core.$strip>>>>;
23
+ delivery: z.ZodOptional<z.ZodEnum<{
24
+ steer: "steer";
25
+ followUp: "followUp";
26
+ }>>;
27
+ recall: z.ZodOptional<z.ZodObject<{
28
+ commandId: z.ZodString;
29
+ requestId: z.ZodString;
30
+ text: z.ZodString;
31
+ images: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodObject<{
32
+ type: z.ZodLiteral<"image">;
33
+ data: z.ZodString;
34
+ mimeType: z.ZodString;
35
+ }, z.core.$strip>>>>;
36
+ delivery: z.ZodEnum<{
37
+ steer: "steer";
38
+ followUp: "followUp";
39
+ }>;
40
+ }, z.core.$strip>>;
41
+ pending: z.ZodArray<z.ZodObject<{
42
+ requestId: z.ZodString;
43
+ content: z.ZodString;
44
+ submittedDraft: z.ZodString;
45
+ delivery: z.ZodEnum<{
46
+ steer: "steer";
47
+ followUp: "followUp";
48
+ }>;
49
+ images: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodObject<{
50
+ type: z.ZodLiteral<"image">;
51
+ data: z.ZodString;
52
+ mimeType: z.ZodString;
53
+ }, z.core.$strip>>>>;
54
+ restoreDraft: z.ZodBoolean;
55
+ }, z.core.$strip>>;
56
+ }, z.core.$strip>;
57
+ type DraftJournal = z.infer<typeof journal>;
58
+ /** One owner-scoped journal commits pending text and composer text together. */
59
+ export declare function draftJournal(scope: string, storage: ParakeetDraftStorage | undefined): {
60
+ read(): DraftJournal;
61
+ write(value: DraftJournal): void;
62
+ };
63
+ export {};
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ const images = z
3
+ .array(z.object({
4
+ type: z.literal("image"),
5
+ data: z.string(),
6
+ mimeType: z.string(),
7
+ }))
8
+ .readonly();
9
+ const delivery = z.enum(["steer", "followUp"]);
10
+ const recalledPrompt = z.object({
11
+ commandId: z.string(),
12
+ requestId: z.string(),
13
+ text: z.string(),
14
+ images: images.optional(),
15
+ delivery,
16
+ });
17
+ const pendingDraft = z.object({
18
+ requestId: z.string().min(1),
19
+ content: z.string(),
20
+ submittedDraft: z.string(),
21
+ delivery,
22
+ images: images.optional(),
23
+ restoreDraft: z.boolean(),
24
+ });
25
+ const journal = z.object({
26
+ draft: z.string(),
27
+ images: images.optional(),
28
+ delivery: delivery.optional(),
29
+ recall: recalledPrompt.optional(),
30
+ pending: z.array(pendingDraft).max(32),
31
+ });
32
+ /** One owner-scoped journal commits pending text and composer text together. */
33
+ export function draftJournal(scope, storage) {
34
+ const key = `fungi.parakeet.drafts.v1:${scope}`;
35
+ return {
36
+ read() {
37
+ if (storage === undefined)
38
+ return { draft: "", pending: [] };
39
+ const saved = storage.getItem(key);
40
+ return saved === null
41
+ ? { draft: "", pending: [] }
42
+ : journal.parse(JSON.parse(saved));
43
+ },
44
+ write(value) {
45
+ if (storage === undefined)
46
+ throw new Error("Durable draft storage is unavailable");
47
+ storage.setItem(key, JSON.stringify(value));
48
+ },
49
+ };
50
+ }
@@ -1,3 +1,5 @@
1
1
  import type { ParakeetModel } from "../conversation.js";
2
2
  /** The durable assistant's provider/model ids, shared by browser and terminal. */
3
3
  export declare function formatModelIdentity(model: ParakeetModel): string;
4
+ /** The model identity with its thinking level, "provider/model (level)"; no level, no suffix. */
5
+ export declare function formatModelWithLevel(model: ParakeetModel, level: string | undefined): string;
@@ -5,3 +5,9 @@ export function formatModelIdentity(model) {
5
5
  ? model.model
6
6
  : `${prefix}${model.model}`;
7
7
  }
8
+ /** The model identity with its thinking level, "provider/model (level)"; no level, no suffix. */
9
+ export function formatModelWithLevel(model, level) {
10
+ return level === undefined
11
+ ? formatModelIdentity(model)
12
+ : `${formatModelIdentity(model)} (${level})`;
13
+ }
@@ -0,0 +1,35 @@
1
+ import type { SessionPendingPrompt, ShiitakeSession } from "@fungi.computer/shiitake/client";
2
+ /** Ephemeral composer reservation, never an admission journal or queue. */
3
+ export type QueuedRecallState = Readonly<{
4
+ /** Whether recall is available, reserved, or restored into the composer. */
5
+ kind: "idle" | "recalling" | "restored";
6
+ }> | Readonly<{
7
+ /** A definitive refusal or an unresolved owner response. */
8
+ kind: "too_late" | "uncertain";
9
+ /** Explanation suitable for the host's existing toast surface. */
10
+ message: string;
11
+ }>;
12
+ /** Result of one recall gesture; an empty queue permits local input history. */
13
+ export type QueuedRecallOutcome = "ignored" | "empty" | "restored" | "too_late" | "uncertain";
14
+ /** Composer boundary consumed by the queued-recall owner. */
15
+ export type RecallComposer = Readonly<{
16
+ session: Pick<ShiitakeSession, "latestPending" | "cancel">;
17
+ recovery: SessionPendingPrompt | undefined;
18
+ /** Commit in the existing Session draft journal before cancellation can start. */
19
+ reserve: (prompt: SessionPendingPrompt) => void;
20
+ /** Clear that journal's reservation after a definitive TooLate. */
21
+ forget: () => void;
22
+ /** Undefined when text, attachments or a pending paste make recall unsafe. */
23
+ emptyDraftRevision: () => number | undefined;
24
+ /** Atomically restore the full payload iff this empty revision still owns input. */
25
+ restore: (prompt: SessionPendingPrompt, revision: number) => boolean;
26
+ /** Hosts use their existing toast seam and derive read-only from `recalling`. */
27
+ onState: (state: QueuedRecallState) => void;
28
+ }>;
29
+ export type QueuedRecall = Readonly<{
30
+ readonly state: QueuedRecallState;
31
+ recall: () => Promise<QueuedRecallOutcome>;
32
+ dispose: () => void;
33
+ }>;
34
+ /** One instance belongs to one mounted Session composer, not a surface or Agent. */
35
+ export declare function createQueuedRecall(composer: RecallComposer): QueuedRecall;
@@ -0,0 +1,138 @@
1
+ /** A private UI wait ceiling; it does not cancel or reinterpret owner work. */
2
+ const recallWaitMs = 5_000;
3
+ const tooLateMessage = "Too late: that message already started";
4
+ function failureMessage(cause) {
5
+ return cause instanceof Error
6
+ ? cause.message
7
+ : "Could not recall queued message";
8
+ }
9
+ /** One instance belongs to one mounted Session composer, not a surface or Agent. */
10
+ export function createQueuedRecall(composer) {
11
+ let state = { kind: "idle" };
12
+ let target = composer.recovery === undefined ? undefined : { prompt: composer.recovery };
13
+ let disposed = false;
14
+ const isDisposed = () => disposed;
15
+ let releaseWait;
16
+ let pendingDecision;
17
+ const update = (next) => {
18
+ state = next;
19
+ composer.onState(next);
20
+ };
21
+ async function decide(revision, attempt) {
22
+ if (target === undefined) {
23
+ const prompt = await composer.session.latestPending();
24
+ // A late lookup must never begin cancellation after input was unlocked.
25
+ if (!attempt.active || isDisposed())
26
+ return { kind: "ignored" };
27
+ if (composer.emptyDraftRevision() !== revision)
28
+ return { kind: "ignored" };
29
+ if (prompt === null)
30
+ return { kind: "empty" };
31
+ composer.reserve(prompt);
32
+ target = { prompt };
33
+ }
34
+ const selected = target;
35
+ const result = selected.result ??
36
+ (await composer.session.cancel({
37
+ commandId: selected.prompt.commandId,
38
+ onlyIfPending: true,
39
+ }));
40
+ // An ambiguous timeout retains this result for a deliberate subsequent Up.
41
+ // Never mutate another target or restore input from a late response.
42
+ if (!isDisposed() && target === selected)
43
+ selected.result = result;
44
+ return { kind: "decided", target: selected, result };
45
+ }
46
+ async function recall() {
47
+ const revision = composer.emptyDraftRevision();
48
+ if (isDisposed() || state.kind === "recalling" || revision === undefined)
49
+ return "ignored";
50
+ // A UI timeout cannot abort the owner's Promise. Do not accumulate hidden
51
+ // requests or waiters while it is unsettled; another Up can recover its
52
+ // result once it arrives, or retry this exact target after a rejection.
53
+ if (pendingDecision !== undefined)
54
+ return "uncertain";
55
+ update({ kind: "recalling" });
56
+ const attempt = { active: true };
57
+ const interrupted = new Promise((resolve) => {
58
+ releaseWait = () => {
59
+ resolve({ kind: "ignored" });
60
+ };
61
+ });
62
+ let timer;
63
+ const timeout = new Promise((resolve) => {
64
+ timer = setTimeout(() => {
65
+ attempt.active = false;
66
+ resolve({
67
+ kind: "uncertain",
68
+ message: "Recall is unconfirmed. Press Up on an empty composer to retry.",
69
+ });
70
+ }, recallWaitMs);
71
+ });
72
+ pendingDecision = decide(revision, attempt)
73
+ .catch((cause) => ({
74
+ kind: "uncertain",
75
+ message: failureMessage(cause),
76
+ }))
77
+ .finally(() => {
78
+ pendingDecision = undefined;
79
+ });
80
+ const decision = await Promise.race([
81
+ pendingDecision,
82
+ timeout,
83
+ interrupted,
84
+ ]);
85
+ attempt.active = false;
86
+ clearTimeout(timer);
87
+ releaseWait = undefined;
88
+ if (isDisposed())
89
+ return "ignored";
90
+ if (decision.kind === "ignored" || decision.kind === "empty") {
91
+ update({ kind: "idle" });
92
+ return decision.kind;
93
+ }
94
+ if (decision.kind === "uncertain") {
95
+ update(decision);
96
+ return "uncertain";
97
+ }
98
+ if (decision.result.status === "too_late") {
99
+ try {
100
+ composer.forget();
101
+ }
102
+ catch (cause) {
103
+ update({ kind: "uncertain", message: failureMessage(cause) });
104
+ return "uncertain";
105
+ }
106
+ target = undefined;
107
+ update({ kind: "too_late", message: tooLateMessage });
108
+ return "too_late";
109
+ }
110
+ try {
111
+ if (composer.restore(decision.target.prompt, revision)) {
112
+ target = undefined;
113
+ update({ kind: "restored" });
114
+ return "restored";
115
+ }
116
+ update({
117
+ kind: "uncertain",
118
+ message: "Message recalled. Clear the composer and press Up to restore it.",
119
+ });
120
+ }
121
+ catch (cause) {
122
+ // Keep the accepted payload if, for example, the existing draft journal
123
+ // refused the restore. A later Up retries restoration, not cancellation.
124
+ update({ kind: "uncertain", message: failureMessage(cause) });
125
+ }
126
+ return "uncertain";
127
+ }
128
+ return {
129
+ get state() {
130
+ return state;
131
+ },
132
+ recall,
133
+ dispose: () => {
134
+ disposed = true;
135
+ releaseWait?.();
136
+ },
137
+ };
138
+ }
@@ -116,6 +116,18 @@ export function applySessionFrame(current, frame) {
116
116
  compactions: mergeCompactions([], frame.baseline.compactions ?? []),
117
117
  compactionNotices: mergeCompactionNotices([], frame.baseline.compactionNotices ?? []),
118
118
  };
119
+ if (frame.kind === "tool_output") {
120
+ const completed = new Set(current.messages.flatMap((message) => message.value.role === "toolResult" ? [message.value.toolCallId] : []));
121
+ const toolOutput = frame.toolOutput
122
+ .filter((slot) => !completed.has(slot.toolCallId))
123
+ .map((slot) => {
124
+ const previous = current.toolOutput.find((value) => value.toolCallId === slot.toolCallId);
125
+ return previous !== undefined && previous.revision > slot.revision
126
+ ? previous
127
+ : slot;
128
+ });
129
+ return { ...current, activity: frame.activity, toolOutput };
130
+ }
119
131
  if (frame.kind === "progress") {
120
132
  const completed = completedMessageIds(current.messages).has(frame.progress.messageId);
121
133
  const currentProgress = current.progress;
@@ -141,6 +153,9 @@ export function applySessionFrame(current, frame) {
141
153
  ? current.pending
142
154
  : frame.pending;
143
155
  const messages = mergeMessages(current.messages, frame.messages);
156
+ const completedTools = new Set(messages.flatMap((message) => message.value.role === "toolResult" ? [message.value.toolCallId] : []));
157
+ const toolOutput = current.toolOutput.filter((slot) => frame.activity.queue.state !== "idle" &&
158
+ !completedTools.has(slot.toolCallId));
144
159
  const terminals = mergeTerminals(current.terminals, frame.terminals);
145
160
  const currentCompactions = current.compactions ?? [];
146
161
  const compactions = mergeCompactions(currentCompactions, frame.compactions ?? []);
@@ -160,6 +175,7 @@ export function applySessionFrame(current, frame) {
160
175
  compactions === currentCompactions &&
161
176
  compactionNotices === currentCompactionNotices &&
162
177
  progress === current.progress &&
178
+ toolOutput.length === current.toolOutput.length &&
163
179
  pending === current.pending &&
164
180
  sameExtensionSlots(frame.extensions, current.extensions) &&
165
181
  sameAttention(frame.attention, current.attention) &&
@@ -175,6 +191,7 @@ export function applySessionFrame(current, frame) {
175
191
  compactions,
176
192
  compactionNotices,
177
193
  progress,
194
+ toolOutput,
178
195
  activity: sameActivity(current.activity, frame.activity)
179
196
  ? current.activity
180
197
  : frame.activity,