@kingcrab/pi-imessage 1.1.1 → 1.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 (84) hide show
  1. package/README.md +4 -6
  2. package/dist/agent/behavior.js +7 -13
  3. package/dist/agent/chats.js +7 -8
  4. package/dist/agent/commands.js +16 -11
  5. package/dist/agent/harness.js +6 -0
  6. package/dist/agent/progress.js +30 -4
  7. package/dist/agent/prompt.js +41 -24
  8. package/dist/agent/run.js +5 -4
  9. package/dist/agent/scheduling.js +11 -34
  10. package/dist/cli.js +42 -5
  11. package/dist/config/settings.js +4 -0
  12. package/dist/extensions/browser.js +7 -3
  13. package/dist/extensions/codemode.js +16 -0
  14. package/dist/extensions/final-text.js +12 -0
  15. package/dist/extensions/schedules.js +1 -1
  16. package/dist/extensions/subagent.js +3 -12
  17. package/dist/extensions/workspace.js +6 -2
  18. package/dist/main.js +42 -49
  19. package/dist/transport/watch.js +7 -1
  20. package/dist/web/app.js +63 -121
  21. package/dist/web/server.js +7 -14
  22. package/dist/web/style.css +0 -6
  23. package/dist/web/tasks.js +12 -120
  24. package/docs/adr/{0004-native-extensions.md → 0003-native-extensions.md} +1 -1
  25. package/docs/adr/{0006-jsonl-storage.md → 0004-jsonl-storage.md} +1 -1
  26. package/docs/adr/{0007-images-by-path.md → 0005-images-by-path.md} +1 -1
  27. package/docs/adr/{0010-test-budget.md → 0006-test-budget.md} +1 -1
  28. package/docs/adr/{0011-reconcile-unsent-answers.md → 0007-reconcile-unsent-answers.md} +2 -2
  29. package/docs/adr/{0012-run-in-owned-conversation.md → 0008-run-in-owned-conversation.md} +5 -5
  30. package/docs/adr/{0032-native-background-schedules.md → 0009-native-background-schedules.md} +1 -1
  31. package/docs/adr/0010-framework-and-workspace.md +27 -0
  32. package/docs/adr/README.md +24 -48
  33. package/docs/api.md +16 -8
  34. package/docs/architecture.md +19 -8
  35. package/docs/browser.md +7 -7
  36. package/docs/install.md +109 -0
  37. package/docs/workspace-extensions.md +8 -4
  38. package/package.json +4 -5
  39. package/dist/agent/compaction.js +0 -51
  40. package/dist/agent/error-log.js +0 -37
  41. package/dist/extensions/restart.js +0 -100
  42. package/docs/adr/0003-replies-at-most-once.md +0 -11
  43. package/docs/adr/0005-history-migration.md +0 -11
  44. package/docs/adr/0008-compaction-and-run.md +0 -11
  45. package/docs/adr/0009-one-shot-cutover.md +0 -11
  46. package/docs/adr/0013-thin-adapters-around-durable.md +0 -18
  47. package/docs/adr/0014-ephemeral-private-storage.md +0 -13
  48. package/docs/adr/0015-ordinary-stop-keeps-native-inbox.md +0 -21
  49. package/docs/adr/0016-process-http-transport.md +0 -21
  50. package/docs/adr/0017-browser-cli-owned-scope.md +0 -22
  51. package/docs/adr/0018-tasks-without-prompt-api.md +0 -19
  52. package/docs/adr/0019-remove-scheduled-services.md +0 -41
  53. package/docs/adr/0020-chat-task-progress.md +0 -41
  54. package/docs/adr/0021-readable-progress-without-link-previews.md +0 -33
  55. package/docs/adr/0022-progress-admission-and-settlement.md +0 -35
  56. package/docs/adr/0023-retain-progress-type-prefixes.md +0 -18
  57. package/docs/adr/0024-status-marker-before-type-prefix.md +0 -13
  58. package/docs/adr/0025-reusable-progress-and-command-preview.md +0 -49
  59. package/docs/adr/0026-inline-command-only-requests.md +0 -16
  60. package/docs/adr/0027-command-preview-width.md +0 -10
  61. package/docs/adr/0028-command-preview-120.md +0 -8
  62. package/docs/adr/0030-shared-web-read-cache.md +0 -33
  63. package/docs/adr/0031-quiet-compaction-progress.md +0 -32
  64. package/docs/adr/0033-operator-scheduled-run.md +0 -23
  65. package/docs/adr/0034-machine-local-schedule-time.md +0 -36
  66. package/docs/adr/0035-external-business-task-extensions.md +0 -56
  67. package/docs/adr/0036-workspace-policy-and-neutral-service-id.md +0 -37
  68. package/docs/adr/0037-restart-during-in-flight-work.md +0 -30
  69. package/docs/adr/0038-restart-any-time.md +0 -25
  70. package/docs/adr/0039-rerun-interrupted-tools.md +0 -24
  71. package/docs/adr/0040-chat-progress-notices.md +0 -44
  72. package/docs/adr/0041-restart-and-recovery.md +0 -28
  73. package/docs/adr/0042-background-schedules-and-workspace-extensions.md +0 -44
  74. package/docs/adr/0043-remove-legacy-migration.md +0 -23
  75. package/docs/adr/0044-message-channels.md +0 -32
  76. package/docs/adr/0045-chat-scheduling-tools.md +0 -51
  77. package/docs/adr/0046-selective-command-redaction.md +0 -31
  78. package/docs/adr/0047-one-scheduling-path.md +0 -158
  79. package/docs/adr/0048-quiet-attachment-reads.md +0 -11
  80. package/docs/adr/0049-per-chat-progress-notices.md +0 -21
  81. package/docs/adr/0052-host-owned-self-restart.md +0 -53
  82. package/docs/adr/0053-service-log-errors.md +0 -44
  83. package/ops/README.md +0 -250
  84. package/ops/preflight.mjs +0 -28
@@ -1,3 +1,15 @@
1
+ /** Text written next to tool calls in one assistant message: the model's interim update. */
2
+ export function interimText(message) {
3
+ if (message?.role !== "assistant" || message.stopReason !== "toolUse")
4
+ return;
5
+ if (!message.content.some((part) => part.type === "toolCall"))
6
+ return;
7
+ const text = message.content
8
+ .map((part) => (part.type === "text" ? part.text : ""))
9
+ .join("\n")
10
+ .trim();
11
+ return text || undefined;
12
+ }
1
13
  /** The same final-only projection is used for transport and the model's delivery facts. */
2
14
  export function finalReplyText(message) {
3
15
  if (message?.role !== "assistant" || !["stop", "length"].includes(message.stopReason))
@@ -8,7 +8,7 @@ export const ScheduledOutbox = defineDoc({
8
8
  checkpointWhen: () => false,
9
9
  });
10
10
  export function validateSchedules(extensions) {
11
- const ids = new Set(["compact-chats"]);
11
+ const ids = new Set();
12
12
  const requestIds = new Set();
13
13
  for (const extension of extensions) {
14
14
  for (const schedule of extension.schedules ?? []) {
@@ -6,28 +6,19 @@ export const Subagent = defineExtension({
6
6
  tools: [
7
7
  defineTool({
8
8
  name: "subagent",
9
- description: "Run a foreground read-only subagent with a separate context. Returns its result here.",
9
+ description: "Run a foreground subagent with a separate context and the same tools as you. Returns its result here.",
10
10
  parameters: Type.Object({ task: Type.String() }),
11
11
  replay: "safe",
12
12
  async execute(args, api, context) {
13
- const parent = await api.agent(context);
14
13
  const childId = await api.commit(async (tx) => {
15
14
  const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
16
15
  if (existing)
17
16
  return existing.id;
18
17
  const child = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
19
- const permitted = [
20
- "read",
21
- "search_memory",
22
- "load_memory",
23
- "web_search",
24
- "fetch_content",
25
- "get_search_results",
26
- ];
18
+ // The child has every tool its parent has, deferred ones included; only nesting another subagent is left out.
27
19
  await configure(tx, child.id, {
28
20
  extensions: { remove: [Subagent] },
29
- tools: parent.tools.filter((tool) => permitted.includes(tool.name)),
30
- instructions: "Complete only the delegated task. You are read-only. Do not send messages or launch background work.",
21
+ instructions: "Complete only the delegated task and return its result; do not take on other work.",
31
22
  });
32
23
  return child.id;
33
24
  }, context);
@@ -2,7 +2,8 @@ import { readdir, readFile, realpath } from "node:fs/promises";
2
2
  import { createRequire } from "node:module";
3
3
  import { join, sep } from "node:path";
4
4
  import { Type } from "@earendil-works/pi-ai";
5
- import { configure, defineDoc, defineExtension, defineTask, defineTool, section, } from "@earendil-works/pi-durable";
5
+ import * as durable from "@earendil-works/pi-durable";
6
+ import { configure, defineDoc, defineExtension, defineTask, defineTool, GenerationTask, hook, section, } from "@earendil-works/pi-durable";
6
7
  import { ScheduledOutbox, validateSchedules, } from "./schedules.js";
7
8
  const loadModule = createRequire(import.meta.url);
8
9
  /** A chat assistant can apply its own workspace changes through the existing host owner. */
@@ -22,7 +23,7 @@ export function workspaceExtension(reload) {
22
23
  ],
23
24
  });
24
25
  }
25
- /** Scan only direct workspace extension directories; never skills or attachments (ADR 0035). */
26
+ /** Scan only direct workspace extension directories; never skills or attachments. */
26
27
  export async function loadWorkspaceExtensions(workingDir) {
27
28
  const extensions = [];
28
29
  const root = join(workingDir, "extensions");
@@ -69,6 +70,9 @@ export async function loadWorkspaceExtensions(workingDir) {
69
70
  configure,
70
71
  ScheduledOutbox,
71
72
  section,
73
+ hook,
74
+ GenerationTask,
75
+ durable,
72
76
  });
73
77
  if (!extension ||
74
78
  typeof extension !== "object" ||
package/dist/main.js CHANGED
@@ -1,13 +1,12 @@
1
1
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
2
  import { SettingsManager } from "@earendil-works/pi-coding-agent";
3
3
  import { createRegistry } from "@earendil-works/pi-durable";
4
+ import { createToolSearchExtension } from "@earendil-works/pi-durable/tool-search";
4
5
  import { CodingTools } from "@earendil-works/pi-durable/tools";
5
6
  import { Chats, chatConversation, submitMessage, WatchCursor, } from "./agent/chats.js";
6
7
  import { compactionReply, isCommand, runCommand } from "./agent/commands.js";
7
- import { compactChats } from "./agent/compaction.js";
8
8
  import { deliverReplies, recoverSending } from "./agent/deliver.js";
9
9
  import { deliverDirect } from "./agent/direct-send.js";
10
- import { logErrors } from "./agent/error-log.js";
11
10
  import { openHarness } from "./agent/harness.js";
12
11
  import { submitHealth } from "./agent/health.js";
13
12
  import { openModels, readDefaults, requestSettings, withModelPolicy } from "./agent/models.js";
@@ -17,10 +16,10 @@ import { Runs, runRecord } from "./agent/run.js";
17
16
  import { deliverScheduled, EXECUTION_KIND, Schedules, schedulingExtension } from "./agent/scheduling.js";
18
17
  import { isReplyEnabled, readSettings } from "./config/settings.js";
19
18
  import { createBrowser } from "./extensions/browser.js";
19
+ import { createCodemode } from "./extensions/codemode.js";
20
20
  import { memoryExtension } from "./extensions/memory.js";
21
21
  import { messageExtension } from "./extensions/message.js";
22
22
  import { ImageRead } from "./extensions/read-image.js";
23
- import { launchInTerminal, restartExtension } from "./extensions/restart.js";
24
23
  import { validateSchedules } from "./extensions/schedules.js";
25
24
  import { Subagent } from "./extensions/subagent.js";
26
25
  import { webExtension } from "./extensions/web.js";
@@ -29,6 +28,29 @@ import { archiveAttachments } from "./transport/attachments.js";
29
28
  import { openHttpTransport } from "./transport/http.js";
30
29
  import { createWatcher } from "./transport/watch.js";
31
30
  import { startWeb } from "./web/server.js";
31
+ /** Tools offered upfront; every other built-in or workspace tool waits for tool_search. */
32
+ const OFFERED_TOOLS = new Set([
33
+ "read",
34
+ "bash",
35
+ "edit",
36
+ "write",
37
+ "send_message",
38
+ "search_memory",
39
+ "load_memory",
40
+ "save_memory",
41
+ "web_search",
42
+ "fetch_content",
43
+ "tool_search",
44
+ "codemode",
45
+ ]);
46
+ function deferRare(extension) {
47
+ if (!extension.tools)
48
+ return extension;
49
+ return {
50
+ ...extension,
51
+ tools: extension.tools.map((tool) => OFFERED_TOOLS.has(tool.name) ? tool : { ...tool, deferred: true }),
52
+ };
53
+ }
32
54
  /** Explicit startup; pass an isolated runtime in tests instead of opening installed auth. */
33
55
  export async function startService(options,
34
56
  /** The application owns process HTTP resources; standalone callers retain their transport. */
@@ -46,6 +68,11 @@ reloadHttpTransport) {
46
68
  }
47
69
  const defaults = { ...runtime.defaults, model: { ...runtime.defaults.model } };
48
70
  const browser = await createBrowser(options.workingDir, options.agentDir);
71
+ const codemode = createCodemode();
72
+ const closeResources = async () => {
73
+ await codemode.close();
74
+ await browser.close();
75
+ };
49
76
  if (!runtime.models.getModel(defaults.model.provider, defaults.model.modelId))
50
77
  throw new Error(`Model is unavailable: ${defaults.model.provider}/${defaults.model.modelId}`);
51
78
  async function loadExtensions() {
@@ -72,21 +99,16 @@ reloadHttpTransport) {
72
99
  : [
73
100
  CodingTools,
74
101
  ImageRead,
102
+ // Durable's own tool_search loads the deferred tools.
103
+ createToolSearchExtension(),
75
104
  await memoryExtension(options.workingDir),
76
105
  webExtension(),
77
106
  browser.extension,
107
+ codemode.extension,
78
108
  Subagent,
79
109
  workspaceExtension(reload),
80
- // ADR 0052: the service runs from a release directory; the controller starts after the call settles.
81
- restartExtension({
82
- workingDir: options.workingDir,
83
- currentRelease: process.cwd(),
84
- deployRoot: "/tmp",
85
- settled: (taskId) => harness.waitForTask(taskId, BACKGROUND_CONTEXT),
86
- launch: launchInTerminal,
87
- }),
88
- ]),
89
- ...(await loadWorkspaceExtensions(options.workingDir)),
110
+ ].map(deferRare)),
111
+ ...(await loadWorkspaceExtensions(options.workingDir)).map(deferRare),
90
112
  ];
91
113
  validateSchedules(extensions);
92
114
  const names = new Set();
@@ -95,7 +117,7 @@ reloadHttpTransport) {
95
117
  throw new Error(`Duplicate extension name: ${extension.name}`);
96
118
  names.add(extension.name);
97
119
  }
98
- // ADR 0039: an interrupted tool reruns on recovery unless it declares `unsafe`.
120
+ // An interrupted tool reruns on recovery unless it declares `unsafe`.
99
121
  return extensions.map((extension) => extension.tools
100
122
  ? { ...extension, tools: extension.tools.map((tool) => ({ ...tool, replay: tool.replay ?? "safe" })) }
101
123
  : extension);
@@ -106,7 +128,6 @@ reloadHttpTransport) {
106
128
  const { harness, storage } = owner;
107
129
  const scheduling = schedulingExtension(harness, defaults);
108
130
  let progress;
109
- const stopErrorLog = logErrors(harness);
110
131
  try {
111
132
  // The native owner lock is acquired before cleaning up browsers left by a previous process.
112
133
  await browser.close();
@@ -129,7 +150,6 @@ reloadHttpTransport) {
129
150
  const jobId = input && typeof input === "object" && !Array.isArray(input) ? input.jobId : undefined;
130
151
  const saved = savedSchedules?.items.find((schedule) => schedule.id === jobId);
131
152
  if (saved &&
132
- saved.id !== "compact-chats" &&
133
153
  !activeExtensions.some((extension) => extension.schedules?.some((schedule) => schedule.id === saved.id)))
134
154
  throw new Error(`Missing extension for unfinished schedule: ${saved.id}`);
135
155
  }
@@ -141,7 +161,7 @@ reloadHttpTransport) {
141
161
  });
142
162
  }
143
163
  catch (error) {
144
- await owner.close(browser.close);
164
+ await owner.close(closeResources);
145
165
  throw error;
146
166
  }
147
167
  let reloading = Promise.resolve();
@@ -362,24 +382,6 @@ reloadHttpTransport) {
362
382
  return receipt;
363
383
  });
364
384
  },
365
- /** Admit quiet native compactions; observing their results must not hold shutdown open. */
366
- compact() {
367
- return track(async () => {
368
- const tasks = await compactChats(harness);
369
- for (const task of tasks)
370
- void harness
371
- .waitForTask(task, BACKGROUND_CONTEXT)
372
- .then((settled) => {
373
- if (settled.state.outcome.status !== "completed")
374
- console.warn("Scheduled compaction ended", task, settled.state.outcome);
375
- })
376
- .catch((error) => {
377
- if (!closed)
378
- console.warn("Scheduled compaction observation failed", task, error);
379
- });
380
- return tasks;
381
- });
382
- },
383
385
  // Explicit host boundary; no model loop, timer or transport runs on import.
384
386
  deliver() {
385
387
  return track(async () => {
@@ -396,8 +398,7 @@ reloadHttpTransport) {
396
398
  closing ??= (async () => {
397
399
  await Promise.allSettled([...operations]);
398
400
  await progress.close();
399
- stopErrorLog();
400
- await owner.close(browser.close);
401
+ await owner.close(closeResources);
401
402
  })();
402
403
  return closing;
403
404
  },
@@ -449,15 +450,10 @@ export async function startMessaging(options, reloadHttpTransport) {
449
450
  let closing;
450
451
  let timer;
451
452
  let started = false;
452
- /** Rows committed before the call are read even when a poll is already in flight. */
453
453
  function poll() {
454
454
  if (closed)
455
455
  return Promise.reject(new Error("Messaging is closed"));
456
- const previous = pending?.catch(() => undefined) ?? Promise.resolve();
457
- const current = previous
458
- .then(async () => {
459
- if (closed)
460
- return;
456
+ pending ??= (async () => {
461
457
  try {
462
458
  await watcher.poll();
463
459
  }
@@ -466,13 +462,10 @@ export async function startMessaging(options, reloadHttpTransport) {
466
462
  if (!closed)
467
463
  await service.deliver();
468
464
  }
469
- })
470
- .finally(() => {
471
- if (pending === current)
472
- pending = undefined;
465
+ })().finally(() => {
466
+ pending = undefined;
473
467
  });
474
- pending = current;
475
- return current;
468
+ return pending;
476
469
  }
477
470
  function tick() {
478
471
  void poll()
@@ -62,7 +62,13 @@ export function createWatcher(options) {
62
62
  const attachments = db
63
63
  .prepare(`SELECT a.filename FROM attachment a
64
64
  JOIN message_attachment_join j ON j.attachment_id=a.ROWID WHERE j.message_id=?`)
65
- .all(row.rowid).flatMap(({ filename }) => (filename ? [filename.replace(/^~(?=\/)/, homedir())] : []));
65
+ .all(row.rowid).flatMap(({ filename }) =>
66
+ // Link-preview and iMessage app payload plists carry nothing a model can read; the text already
67
+ // holds the URL. Messages may record one in a transient transcoder directory and delete the
68
+ // file, so archiving it would hold this row forever.
69
+ filename && !filename.endsWith(".pluginPayloadAttachment")
70
+ ? [filename.replace(/^~(?=\/)/, homedir())]
71
+ : []);
66
72
  const reply = row.replyGuid
67
73
  ? db.prepare("SELECT text,attributedBody FROM message WHERE guid=?").get(row.replyGuid)
68
74
  : undefined;
package/dist/web/app.js CHANGED
@@ -427,15 +427,18 @@ function renderSettings() {
427
427
 
428
428
  function renderChatSettings() {
429
429
  const chats = data.chats || [];
430
- const overrides = chats.filter((chat) => chat.verbose.chat !== undefined).length;
430
+ const overrides = chats.filter(
431
+ (chat) => chat.verbose.chat !== undefined || chat.interim.chat !== undefined,
432
+ ).length;
431
433
  view.replaceChildren(
432
434
  element(
433
435
  "p",
434
436
  [
435
437
  `${chats.length} chats`,
436
438
  `default verbose ${data.defaults?.progressMessages ? "on" : "off"}`,
439
+ `default interim ${data.defaults?.interimMessages ? "on" : "off"}`,
437
440
  `${overrides} ${overrides === 1 ? "override" : "overrides"}`,
438
- "read-only: /verbose and /thinking change a chat, settings.json the default",
441
+ "read-only: /verbose, /interim and /thinking change a chat, settings.json the default",
439
442
  ].join(" · "),
440
443
  "muted",
441
444
  ),
@@ -455,7 +458,7 @@ function renderChatSettings() {
455
458
  card.append(header);
456
459
  const table = element("table", undefined, "chat-table");
457
460
  const heading = table.createTHead().insertRow();
458
- for (const label of ["Chat", "Replies", "Verbose", "Model", "Thinking", "Last activity"])
461
+ for (const label of ["Chat", "Replies", "Verbose", "Interim", "Model", "Thinking", "Last activity"])
459
462
  heading.append(element("th", label));
460
463
  const body = table.createTBody();
461
464
  for (const chat of rows) {
@@ -476,6 +479,10 @@ function renderChatSettings() {
476
479
  chat.verbose.effective ? "on" : "off",
477
480
  chat.verbose.chat === undefined ? "default" : "this chat",
478
481
  ),
482
+ stacked(
483
+ chat.interim.effective ? "on" : "off",
484
+ chat.interim.chat === undefined ? "default" : "this chat",
485
+ ),
479
486
  stacked(modelId.join("/") || "-", chat.model ? provider : undefined),
480
487
  stacked(chat.thinking || "-"),
481
488
  stacked(activity || "-"),
@@ -615,6 +622,7 @@ function renderScheduled() {
615
622
 
616
623
  function renderTasks() {
617
624
  view.replaceChildren();
625
+ const children = new Map();
618
626
  const conversations = new Map(data.conversations.map((conversation) => [conversation.id, conversation]));
619
627
  const chatNames = new Map(
620
628
  (data.chats?.items || []).map((chat) => [
@@ -622,130 +630,70 @@ function renderTasks() {
622
630
  chatName({ ...chat, history: { groupName: conversations.get(chat.conversationId)?.groupName } }, []),
623
631
  ]),
624
632
  );
633
+ const conversationNames = new Map(chatNames);
625
634
  const scheduleNames = new Map((data.schedules?.items || []).map((job) => [job.conversationId, job.name]));
626
- const sessionNames = new Map(
627
- (data.sessions?.items || []).map((session) => [session.conversationId, session.label]),
628
- );
629
- // Every conversation is named by the session document that created it, or by the task that owns it.
630
- function source(id) {
631
- if (chatNames.has(id)) return { name: `chat ${chatNames.get(id)}`, doc: "imessage.chats" };
632
- if (scheduleNames.has(id))
633
- return { name: `schedule ${scheduleNames.get(id)}`, doc: "imessage.schedules" };
634
- if (sessionNames.has(id)) return { name: `session ${sessionNames.get(id)}`, doc: "imessage.sessions" };
635
+ for (const [id, name] of scheduleNames) conversationNames.set(id, name);
636
+ for (const session of data.sessions?.items || []) {
637
+ if (!conversationNames.has(session.conversationId))
638
+ conversationNames.set(session.conversationId, session.label);
639
+ }
640
+ function conversationName(id) {
641
+ const name = conversationNames.get(id);
642
+ if (name) return name;
635
643
  const owner = conversations.get(id)?.owner;
636
- return owner ? { name: "subagent", doc: "owned by a tool" } : { name: "", doc: "" };
644
+ if (!owner) return "";
645
+ const parentName = conversationName(owner.conversationId);
646
+ return parentName ? `${parentName} / subagent` : "subagent";
637
647
  }
638
648
  function belongsToChat(id) {
639
649
  if (chatNames.has(id) || scheduleNames.has(id)) return true;
640
650
  const owner = conversations.get(id)?.owner;
641
651
  return owner ? belongsToChat(owner.conversationId) : false;
642
652
  }
643
- // Children by storage relation: conversations under their owner task, tasks under their owner task or
644
- // conversation, entries under the task that wrote them (byTaskId). A pi.user entry opens a turn: the
645
- // later root tasks and loose entries of its conversation nest under it until the next one.
646
- const children = new Map();
647
- const add = (key, node) => {
653
+ for (const conversation of data.conversations.toSorted(compareConversations)) {
654
+ const key = conversation.owner ? `task-${conversation.owner.taskId}` : "root";
648
655
  if (!children.has(key)) children.set(key, []);
649
- children.get(key).push(node);
650
- };
651
- const taskIds = new Set(data.tasks.map((task) => task.id));
652
- for (const conversation of data.conversations) {
653
- if (conversation.owner)
654
- add(`task-${conversation.owner.taskId}`, { type: "conversation", ...conversation });
655
- }
656
- const turns = new Map();
657
- for (const entry of data.entries || []) {
658
- if (entry.kind !== "pi.user") continue;
659
- if (!turns.has(entry.conversationId)) turns.set(entry.conversationId, []);
660
- turns.get(entry.conversationId).push(entry.id);
661
- }
662
- // Entries arrive newest first; a turn lookup needs ascending ids.
663
- for (const ids of turns.values()) ids.sort((left, right) => left - right);
664
- function turnKey(conversationId, id) {
665
- const opened = (turns.get(conversationId) || []).filter((turn) => turn < id).at(-1);
666
- return opened === undefined ? `conversation-${conversationId}` : `entry-${opened}`;
656
+ children.get(key).push({ type: "conversation", ...conversation });
667
657
  }
668
658
  for (const task of data.tasks) {
669
- add(task.owner === undefined ? turnKey(task.conversationId, task.id) : `task-${task.owner}`, {
670
- type: "task",
671
- ...task,
672
- });
673
- }
674
- for (const entry of data.entries || []) {
675
- if (entry.kind === "pi.user") add(`conversation-${entry.conversationId}`, { type: "entry", ...entry });
676
- else if (entry.byTaskId !== undefined && taskIds.has(entry.byTaskId))
677
- add(`task-${entry.byTaskId}`, { type: "entry", ...entry });
678
- else add(turnKey(entry.conversationId, entry.id), { type: "entry", ...entry });
679
- }
680
- // Newest turn first in a card; inside a turn the chain reads in storage order. Cards are sorted by activity.
681
- for (const [key, nodes] of children) {
682
- if (key.startsWith("conversation-")) nodes.sort((left, right) => right.id - left.id);
683
- else nodes.sort((left, right) => left.id - right.id);
684
- }
685
- function lazyDetails(summary, url) {
686
- const detail = element("details");
687
- const content = element("pre", "Loading latest state...");
688
- detail.append(summary, content);
689
- let loaded = false;
690
- detail.addEventListener("toggle", async () => {
691
- if (!detail.open || loaded) return;
692
- loaded = true;
693
- try {
694
- content.textContent = JSON.stringify(await fetchData(url), null, 2);
695
- } catch (error) {
696
- loaded = false;
697
- content.textContent = error.message;
698
- }
699
- });
700
- return detail;
701
- }
702
- function taskSummary(node) {
703
- const label =
704
- node.kind === "pi.generation"
705
- ? "Generation"
706
- : node.kind === "pi.tool"
707
- ? `Tool ${node.name || "?"}`
708
- : node.kind;
709
- const summary = element("summary", label);
710
- const state = node.outcome ? [node.outcome] : [node.status, node.phase].filter(Boolean);
711
- const waiting = node.on?.length ? ` on ${node.on.length} task${node.on.length === 1 ? "" : "s"}` : "";
712
- const sleeping =
713
- typeof node.until === "number" ? ` until ${new Date(node.until).toLocaleString("sv-SE")}` : "";
714
- const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
715
- summary.append(element("span", ` [${state.join(" / ")}]${waiting}${sleeping}${flags}`, "meta"));
716
- return summary;
717
- }
718
- function entrySummary(node) {
719
- const summary = element("summary", node.kind.replace(/^pi\./, ""));
720
- if (node.text) summary.append(element("span", ` "${node.text}"`, "entry-text"));
721
- return summary;
659
+ const key = task.owner === undefined ? `conversation-${task.conversationId}` : `task-${task.owner}`;
660
+ if (!children.has(key)) children.set(key, []);
661
+ children.get(key).push({ type: "task", ...task });
722
662
  }
723
663
  function renderNode(node) {
724
664
  const item = element("li");
725
665
  const key = `${node.type}-${node.id}`;
726
666
  item.id = `tree-${key}`;
727
667
  if (node.type === "conversation") {
728
- const { name, doc } = source(node.id);
729
- // A card is one conversation; a conversation owned by a task folds under it.
730
- const heading = element(node.owner ? "summary" : "span", "Conversation");
731
- if (name) heading.append(element("span", ` ${name}`));
732
- if (doc) heading.append(element("span", ` <- ${doc}`, "meta"));
733
- if (chatNames.has(node.id))
734
- heading.append(element("span", " "), link("Chat", `/chat?conversationId=${node.id}`, "meta"));
735
- if (node.owner) {
736
- const detail = element("details");
737
- detail.id = key;
738
- detail.append(heading);
739
- item.append(detail);
740
- } else item.append(heading);
741
- } else if (node.type === "task") {
742
- const detail = lazyDetails(taskSummary(node), `/tasks/task/data?taskId=${node.id}`);
743
- detail.id = key;
744
- item.append(detail);
668
+ const heading = element("span", `Conversation #${node.id}`);
669
+ const name = conversationName(node.id);
670
+ if (name) heading.append(element("span", ` / ${name} `, "meta"));
671
+ if (chatNames.has(node.id)) {
672
+ heading.append(link("Chat", `/chat?conversationId=${node.id}`, "meta"));
673
+ }
674
+ item.append(heading);
745
675
  } else {
746
- item.className = node.kind === "pi.user" ? "entry turn" : "entry";
747
- const detail = lazyDetails(entrySummary(node), `/tasks/entry/data?entryId=${node.id}`);
676
+ const detail = element("details");
748
677
  detail.id = key;
678
+ const summary = element("summary", `${node.kind}${node.name ? `: ${node.name}` : ""} #${node.id}`);
679
+ const status = [node.status, node.phase, node.outcome].filter(Boolean).join(" / ");
680
+ const waiting = node.on?.length ? ` on [${node.on.join(", ")}]` : "";
681
+ const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
682
+ summary.append(element("span", ` ${status}${waiting}${flags}`, "meta"));
683
+ const content = element("pre", "Loading latest state...");
684
+ detail.append(summary, content);
685
+ let loaded = false;
686
+ detail.addEventListener("toggle", async () => {
687
+ if (!detail.open || loaded) return;
688
+ loaded = true;
689
+ try {
690
+ const task = await fetchData(`/tasks/task/data?taskId=${node.id}`);
691
+ content.textContent = JSON.stringify(task, null, 2);
692
+ } catch (error) {
693
+ loaded = false;
694
+ content.textContent = error.message;
695
+ }
696
+ });
749
697
  item.append(detail);
750
698
  }
751
699
  const descendants = children.get(key) || [];
@@ -753,29 +701,23 @@ function renderTasks() {
753
701
  if (descendants.length || more) {
754
702
  const list = element("ul", undefined, "task-tree");
755
703
  for (const child of descendants) list.append(renderNode(child));
756
- if (more) list.append(element("li", "older turns not listed", "meta"));
757
- (node.type === "conversation" && node.owner ? item.firstChild : item).append(list);
704
+ if (more) list.append(element("li", "older tasks not listed", "meta"));
705
+ item.append(list);
758
706
  }
759
707
  return item;
760
708
  }
761
709
  const roots = conversationId
762
- ? data.conversations.filter((conversation) => String(conversation.id) === conversationId)
763
- : data.conversations.filter((conversation) => !conversation.owner);
710
+ ? data.conversations
711
+ .filter((conversation) => String(conversation.id) === conversationId)
712
+ .map((conversation) => ({ type: "conversation", ...conversation }))
713
+ : children.get("root") || [];
764
714
  const visible = roots.filter((conversation) => belongsToChat(conversation.id));
765
- if (!conversationId && data.session)
766
- view.append(
767
- element(
768
- "p",
769
- `Session ${data.session} docs: imessage.chats, imessage.schedules, imessage.sessions`,
770
- "muted",
771
- ),
772
- );
773
715
  if (!visible.length) view.append(element("p", "No conversations.", "empty"));
774
716
  for (const conversation of visible.toSorted(compareConversations)) {
775
717
  const card = element("section", undefined, "card");
776
718
  card.id = `tasks-${conversation.id}`;
777
719
  const list = element("ul", undefined, "task-tree task-root");
778
- list.append(renderNode({ type: "conversation", ...conversation }));
720
+ list.append(renderNode(conversation));
779
721
  card.append(list);
780
722
  view.append(card);
781
723
  }
@@ -6,7 +6,7 @@ import { promisify } from "node:util";
6
6
  import { gzip } from "node:zlib";
7
7
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
8
8
  import { LiveDoc, } from "@earendil-works/pi-durable";
9
- import { Chats, progressEnabled } from "../agent/chats.js";
9
+ import { Chats, interimEnabled, progressEnabled } from "../agent/chats.js";
10
10
  import { DirectSends } from "../agent/direct-send.js";
11
11
  import { Sessions } from "../agent/health.js";
12
12
  import { Deliveries } from "../agent/replies.js";
@@ -113,7 +113,7 @@ export async function startWeb(options) {
113
113
  settings: { chatAllowlist: configuration.chatAllowlist },
114
114
  };
115
115
  }
116
- /** One row per chat: reply eligibility, the /verbose choice and the conversation's agent settings. */
116
+ /** One row per chat: reply eligibility, the /verbose and /interim choices and the conversation's agent settings. */
117
117
  async function chatSettings() {
118
118
  const [chats, configuration] = await Promise.all([
119
119
  options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
@@ -132,6 +132,7 @@ export async function startWeb(options) {
132
132
  updatedAt: entries ? historyActivity(entries) : undefined,
133
133
  replies: isReplyEnabled(configuration, chat.chatGuid),
134
134
  verbose: { chat: chat.progress, effective: progressEnabled(configuration, records, chat.chatGuid) },
135
+ interim: { chat: chat.interim, effective: interimEnabled(configuration, records, chat.chatGuid) },
135
136
  model: agent?.model ? `${agent.model.provider}/${agent.model.modelId}` : undefined,
136
137
  thinking: agent?.thinkingLevel,
137
138
  };
@@ -139,7 +140,10 @@ export async function startWeb(options) {
139
140
  rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || Number(b.conversationId) - Number(a.conversationId));
140
141
  return {
141
142
  chats: rows,
142
- defaults: { progressMessages: configuration.progressMessages },
143
+ defaults: {
144
+ progressMessages: configuration.progressMessages,
145
+ interimMessages: configuration.interimMessages,
146
+ },
143
147
  revision: viewRevision(),
144
148
  };
145
149
  }
@@ -281,21 +285,10 @@ export async function startWeb(options) {
281
285
  schedules: await options.agent.harness.snapshot(Schedules, BACKGROUND_CONTEXT),
282
286
  chats: await options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
283
287
  sessions: await options.agent.harness.snapshot(Sessions, BACKGROUND_CONTEXT),
284
- session: join(options.workingDir, "durable"),
285
288
  revision: viewRevision(),
286
289
  };
287
290
  compact = true;
288
291
  }
289
- else if (url.pathname === "/tasks/entry/data") {
290
- const id = url.searchParams.get("entryId");
291
- if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
292
- throw new Error("Invalid entry ID");
293
- value = await options.agent.harness.commit((tx) => tx.entry(Number(id)), BACKGROUND_CONTEXT);
294
- if (!value) {
295
- await json(response, 404, { error: "Entry not found" });
296
- return;
297
- }
298
- }
299
292
  else if (url.pathname === "/tasks/task/data") {
300
293
  const id = url.searchParams.get("taskId");
301
294
  if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
@@ -439,12 +439,6 @@ body.page-logs .native-state {
439
439
  padding: 8px 0 8px 16px;
440
440
  }
441
441
 
442
- /* Entries are history records, not work: all grey, no marker of their own. */
443
- .task-tree li.entry > details > summary {
444
- opacity: 0.6;
445
- list-style: none;
446
- }
447
-
448
442
  .schedule-row {
449
443
  background: var(--surface);
450
444
  margin-bottom: 6px;