@kingcrab/pi-imessage 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, chmod, copyFile, mkdtemp, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { Type } from "@earendil-works/pi-ai";
6
+ import { defineExtension, defineTool } from "@earendil-works/pi-durable";
7
+ const execFileAsync = promisify(execFile);
8
+ /** Variables that describe the old shell session or that the controller sets itself. */
9
+ const EXCLUDED_ENVIRONMENT = new Set(["_", "PWD", "OLDPWD", "SHLVL", "TERM_SESSION_ID"]);
10
+ function shellQuote(value) {
11
+ return `'${value.replaceAll("'", "'\\''")}'`;
12
+ }
13
+ async function processField(field) {
14
+ const { stdout } = await execFileAsync("ps", ["-p", String(process.pid), "-o", `${field}=`]);
15
+ // Exactly what the controller's `$(ps ...)` yields: trailing newlines stripped, nothing else.
16
+ return stdout.replace(/\n+$/, "");
17
+ }
18
+ /** Writes the private controller directory of ops/README.md step 3 for the target release; starts nothing. */
19
+ export async function prepareRestart(options, release) {
20
+ if (!/^[0-9a-f]{40}$/.test(release))
21
+ throw new Error("release must be the full commit hash of a prepared release directory");
22
+ const target = join(dirname(options.currentRelease), release);
23
+ for (const entry of ["src/cli.ts", "node_modules", "ops/restart.command"]) {
24
+ await access(join(target, entry)).catch(() => {
25
+ throw new Error(`Release directory is not prepared: ${join(target, entry)} is missing`);
26
+ });
27
+ }
28
+ const [started, command] = await Promise.all([processField("lstart"), processField("command")]);
29
+ const deployDir = await mkdtemp(join(options.deployRoot, "pi-imessage-restart."));
30
+ // The whole process environment, including .env values loaded at startup; the controller deletes the file.
31
+ const environment = Object.entries(process.env)
32
+ .filter(([name, value]) => value !== undefined && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !EXCLUDED_ENVIRONMENT.has(name))
33
+ .map(([name, value]) => `export ${name}=${shellQuote(value ?? "")}`);
34
+ await writeFile(join(deployDir, "environment.sh"), `${environment.join("\n")}\n`, { mode: 0o600 });
35
+ const control = {
36
+ OLD_PID: String(process.pid),
37
+ OLD_STARTED: started,
38
+ EXPECTED_COMMAND: command,
39
+ REPO: target,
40
+ NODE: process.execPath,
41
+ WORKING_DIR: options.workingDir,
42
+ WEB_PORT: process.env.WEB_PORT ?? "7750",
43
+ LOG_FILE: join(options.workingDir, "service.log"),
44
+ };
45
+ await writeFile(join(deployDir, "control.sh"), `${Object.entries(control)
46
+ .map(([name, value]) => `${name}=${shellQuote(value)}`)
47
+ .join("\n")}\n`, { mode: 0o600 });
48
+ const controller = join(deployDir, "restart.command");
49
+ await copyFile(join(target, "ops", "restart.command"), controller);
50
+ await chmod(controller, 0o700);
51
+ return { target, deployDir, controller };
52
+ }
53
+ /** The claw baseline: a Terminal window keeps the controller alive independently of this process. */
54
+ export async function launchInTerminal(controller) {
55
+ await execFileAsync("open", ["-a", "Terminal", controller], { timeout: 15_000 });
56
+ }
57
+ /** The chat assistant restarts the service through the host instead of a bash command that stops its own host. */
58
+ export function restartExtension(options) {
59
+ let scheduled;
60
+ return defineExtension({
61
+ name: "restart",
62
+ tools: [
63
+ defineTool({
64
+ name: "restart_service",
65
+ description: "Restart this service into a prepared release directory: a sibling of the running one named by its full commit hash, with npm ci and ops/preflight.mjs done. The service stops only after this call has settled; end the call at once and verify in a later call: controller.log and new.pid in the returned directory, then the service log, the listener and the running release directory. Never call it again before the first outcome is known.",
66
+ parameters: Type.Object({
67
+ release: Type.String({ description: "Full commit hash of the prepared release directory" }),
68
+ }),
69
+ // A rerun would launch a second controller (#39); an interrupted call is reported to the model instead.
70
+ replay: "unsafe",
71
+ async execute(args, api) {
72
+ if (scheduled)
73
+ throw new Error(`A restart is already scheduled from ${scheduled}`);
74
+ const prepared = await prepareRestart(options, args.release);
75
+ scheduled = prepared.deployDir;
76
+ console.log(new Date().toISOString(), "Restart requested", {
77
+ release: args.release,
78
+ controller: prepared.deployDir,
79
+ task: api.taskId,
80
+ });
81
+ void options
82
+ .settled(api.taskId)
83
+ .then(() => options.launch(prepared.controller))
84
+ .catch((error) => {
85
+ scheduled = undefined;
86
+ console.error(new Date().toISOString(), "Restart launch failed", error);
87
+ });
88
+ return {
89
+ output: [
90
+ {
91
+ type: "text",
92
+ text: `Restart into ${prepared.target} is scheduled; the service stops after this call settles. Controller directory: ${prepared.deployDir}. Verify in a later call.`,
93
+ },
94
+ ],
95
+ };
96
+ },
97
+ }),
98
+ ],
99
+ });
100
+ }
@@ -5,6 +5,7 @@ export const ScheduledOutbox = defineDoc({
5
5
  version: 1,
6
6
  scope: "session",
7
7
  initial: () => ({ items: [] }),
8
+ checkpointWhen: () => false,
8
9
  });
9
10
  export function validateSchedules(extensions) {
10
11
  const ids = new Set(["compact-chats"]);
@@ -41,7 +41,7 @@ export const Subagent = defineExtension({
41
41
  const answer = await api.commit((tx) => tx.entry(result.answer), context);
42
42
  const message = answer?.model?.[0];
43
43
  return {
44
- content: [
44
+ output: [
45
45
  {
46
46
  type: "text",
47
47
  text: finalReplyText(message) ?? "",
@@ -9,6 +9,7 @@ export const WebResults = defineDoc({
9
9
  history: "rewindable",
10
10
  fork: "asOf",
11
11
  initial: () => ({ queries: {} }),
12
+ checkpointWhen: () => false,
12
13
  });
13
14
  async function fetchBounded(url, options = {}) {
14
15
  const parsed = new URL(url);
@@ -65,7 +66,7 @@ export function webExtension(apiKey = process.env.BRAVE_API_KEY ?? process.env.B
65
66
  for (const key of keys.slice(0, Math.max(0, keys.length - 20)))
66
67
  delete results.queries[key];
67
68
  }, context);
68
- return { content: [{ type: "text", text }] };
69
+ return { output: [{ type: "text", text }] };
69
70
  },
70
71
  }),
71
72
  defineTool({
@@ -81,7 +82,7 @@ export function webExtension(apiKey = process.env.BRAVE_API_KEY ?? process.env.B
81
82
  ? (await Defuddle(result.bytes.toString(), result.url, { markdown: true, useAsync: false }))
82
83
  .content
83
84
  : result.bytes.toString();
84
- return { content: [{ type: "text", text: text.slice(0, 50000) }] };
85
+ return { output: [{ type: "text", text: text.slice(0, 50000) }] };
85
86
  },
86
87
  }),
87
88
  defineTool({
@@ -91,7 +92,7 @@ export function webExtension(apiKey = process.env.BRAVE_API_KEY ?? process.env.B
91
92
  replay: "safe",
92
93
  async execute(args, api, context) {
93
94
  const data = await api.snapshot(WebResults, api.conversationId, context);
94
- return { content: [{ type: "text", text: data?.queries[args.query] ?? "No saved result" }] };
95
+ return { output: [{ type: "text", text: data?.queries[args.query] ?? "No saved result" }] };
95
96
  },
96
97
  }),
97
98
  ],
@@ -16,7 +16,7 @@ export function workspaceExtension(reload) {
16
16
  parameters: Type.Object({}),
17
17
  async execute() {
18
18
  await reload();
19
- return { content: [{ type: "text", text: "Workspace extensions and configuration reloaded." }] };
19
+ return { output: [{ type: "text", text: "Workspace extensions and configuration reloaded." }] };
20
20
  },
21
21
  }),
22
22
  ],
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@ import { compactionReply, isCommand, runCommand } from "./agent/commands.js";
7
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";
10
11
  import { openHarness } from "./agent/harness.js";
11
12
  import { submitHealth } from "./agent/health.js";
12
13
  import { openModels, readDefaults, requestSettings, withModelPolicy } from "./agent/models.js";
@@ -19,6 +20,7 @@ import { createBrowser } from "./extensions/browser.js";
19
20
  import { memoryExtension } from "./extensions/memory.js";
20
21
  import { messageExtension } from "./extensions/message.js";
21
22
  import { ImageRead } from "./extensions/read-image.js";
23
+ import { launchInTerminal, restartExtension } from "./extensions/restart.js";
22
24
  import { validateSchedules } from "./extensions/schedules.js";
23
25
  import { Subagent } from "./extensions/subagent.js";
24
26
  import { webExtension } from "./extensions/web.js";
@@ -75,6 +77,14 @@ reloadHttpTransport) {
75
77
  browser.extension,
76
78
  Subagent,
77
79
  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
+ }),
78
88
  ]),
79
89
  ...(await loadWorkspaceExtensions(options.workingDir)),
80
90
  ];
@@ -96,6 +106,7 @@ reloadHttpTransport) {
96
106
  const { harness, storage } = owner;
97
107
  const scheduling = schedulingExtension(harness, defaults);
98
108
  let progress;
109
+ const stopErrorLog = logErrors(harness);
99
110
  try {
100
111
  // The native owner lock is acquired before cleaning up browsers left by a previous process.
101
112
  await browser.close();
@@ -271,6 +282,7 @@ reloadHttpTransport) {
271
282
  models: runtime.models,
272
283
  defaults,
273
284
  reload,
285
+ workingDir: options.workingDir,
274
286
  chatGuid: snapshot.chatGuid,
275
287
  guid: snapshot.guid,
276
288
  text: snapshot.text,
@@ -384,6 +396,7 @@ reloadHttpTransport) {
384
396
  closing ??= (async () => {
385
397
  await Promise.allSettled([...operations]);
386
398
  await progress.close();
399
+ stopErrorLog();
387
400
  await owner.close(browser.close);
388
401
  })();
389
402
  return closing;
@@ -436,10 +449,15 @@ export async function startMessaging(options, reloadHttpTransport) {
436
449
  let closing;
437
450
  let timer;
438
451
  let started = false;
452
+ /** Rows committed before the call are read even when a poll is already in flight. */
439
453
  function poll() {
440
454
  if (closed)
441
455
  return Promise.reject(new Error("Messaging is closed"));
442
- pending ??= (async () => {
456
+ const previous = pending?.catch(() => undefined) ?? Promise.resolve();
457
+ const current = previous
458
+ .then(async () => {
459
+ if (closed)
460
+ return;
443
461
  try {
444
462
  await watcher.poll();
445
463
  }
@@ -448,10 +466,13 @@ export async function startMessaging(options, reloadHttpTransport) {
448
466
  if (!closed)
449
467
  await service.deliver();
450
468
  }
451
- })().finally(() => {
452
- pending = undefined;
469
+ })
470
+ .finally(() => {
471
+ if (pending === current)
472
+ pending = undefined;
453
473
  });
454
- return pending;
474
+ pending = current;
475
+ return current;
455
476
  }
456
477
  function tick() {
457
478
  void poll()
package/dist/web/app.js CHANGED
@@ -16,7 +16,8 @@ document.body.classList.add(`page-${page.slice(1)}`);
16
16
  if (conversationId) document.body.classList.add("conversation-detail");
17
17
  document.getElementById("title").textContent =
18
18
  {
19
- "/chat": "Recent Chats",
19
+ "/chat": "Messages",
20
+ "/chats": "Chats",
20
21
  "/tasks": "Tasks",
21
22
  "/scheduled": "Scheduled tasks",
22
23
  "/logs": "Logs",
@@ -424,6 +425,80 @@ function renderSettings() {
424
425
  }
425
426
  }
426
427
 
428
+ function renderChatSettings() {
429
+ const chats = data.chats || [];
430
+ const overrides = chats.filter((chat) => chat.verbose.chat !== undefined).length;
431
+ view.replaceChildren(
432
+ element(
433
+ "p",
434
+ [
435
+ `${chats.length} chats`,
436
+ `default verbose ${data.defaults?.progressMessages ? "on" : "off"}`,
437
+ `${overrides} ${overrides === 1 ? "override" : "overrides"}`,
438
+ "read-only: /verbose and /thinking change a chat, settings.json the default",
439
+ ].join(" · "),
440
+ "muted",
441
+ ),
442
+ );
443
+ // A main value with its source or provider underneath keeps each column scannable.
444
+ function stacked(main, detail) {
445
+ const cell = element("td", undefined, "c-fix");
446
+ cell.append(element("div", main));
447
+ if (detail) cell.append(element("div", detail, "meta"));
448
+ return cell;
449
+ }
450
+ function section(id, title, rows) {
451
+ const card = element("section", undefined, "card");
452
+ card.id = `chats-${id}`;
453
+ const header = element("header", undefined, "card-header");
454
+ header.append(element("span", title), element("span", String(rows.length), "meta"));
455
+ card.append(header);
456
+ const table = element("table", undefined, "chat-table");
457
+ const heading = table.createTHead().insertRow();
458
+ for (const label of ["Chat", "Replies", "Verbose", "Model", "Thinking", "Last activity"])
459
+ heading.append(element("th", label));
460
+ const body = table.createTBody();
461
+ for (const chat of rows) {
462
+ const row = body.insertRow();
463
+ if (!chat.replies) row.className = "chat-silent";
464
+ const name = row.insertCell();
465
+ // Group name, or the handle of a direct chat; the raw GUID stays in the native state only.
466
+ const handle = chat.chatGuid.includes(";+;") ? "unnamed group" : chat.chatGuid.split(";").at(-1);
467
+ name.append(link(chat.groupName || handle, `/chat?conversationId=${chat.conversationId}`));
468
+ const [provider, ...modelId] = (chat.model || "").split("/");
469
+ const activity =
470
+ typeof chat.updatedAt === "number"
471
+ ? new Date(chat.updatedAt).toLocaleString("sv-SE").slice(0, 16)
472
+ : "";
473
+ row.append(
474
+ stacked(chat.replies ? "on" : "off"),
475
+ stacked(
476
+ chat.verbose.effective ? "on" : "off",
477
+ chat.verbose.chat === undefined ? "default" : "this chat",
478
+ ),
479
+ stacked(modelId.join("/") || "-", chat.model ? provider : undefined),
480
+ stacked(chat.thinking || "-"),
481
+ stacked(activity || "-"),
482
+ );
483
+ }
484
+ card.append(table);
485
+ if (!rows.length) card.append(element("p", "None.", "empty chat-empty"));
486
+ return card;
487
+ }
488
+ view.append(
489
+ section(
490
+ "groups",
491
+ "Groups",
492
+ chats.filter((chat) => chat.chatGuid.includes(";+;")),
493
+ ),
494
+ section(
495
+ "direct",
496
+ "Direct",
497
+ chats.filter((chat) => !chat.chatGuid.includes(";+;")),
498
+ ),
499
+ );
500
+ }
501
+
427
502
  function renderLogs() {
428
503
  const scrollPositions = Array.from(view.querySelectorAll(".card-content"), (node) => ({
429
504
  top: node.scrollTop,
@@ -540,7 +615,6 @@ function renderScheduled() {
540
615
 
541
616
  function renderTasks() {
542
617
  view.replaceChildren();
543
- const children = new Map();
544
618
  const conversations = new Map(data.conversations.map((conversation) => [conversation.id, conversation]));
545
619
  const chatNames = new Map(
546
620
  (data.chats?.items || []).map((chat) => [
@@ -548,92 +622,160 @@ function renderTasks() {
548
622
  chatName({ ...chat, history: { groupName: conversations.get(chat.conversationId)?.groupName } }, []),
549
623
  ]),
550
624
  );
551
- const conversationNames = new Map(chatNames);
552
625
  const scheduleNames = new Map((data.schedules?.items || []).map((job) => [job.conversationId, job.name]));
553
- for (const [id, name] of scheduleNames) conversationNames.set(id, name);
554
- for (const session of data.sessions?.items || []) {
555
- if (!conversationNames.has(session.conversationId))
556
- conversationNames.set(session.conversationId, session.label);
557
- }
558
- function conversationName(id) {
559
- const name = conversationNames.get(id);
560
- if (name) return 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" };
561
635
  const owner = conversations.get(id)?.owner;
562
- if (!owner) return "";
563
- const parentName = conversationName(owner.conversationId);
564
- return parentName ? `${parentName} / subagent` : "subagent";
636
+ return owner ? { name: "subagent", doc: "owned by a tool" } : { name: "", doc: "" };
565
637
  }
566
638
  function belongsToChat(id) {
567
639
  if (chatNames.has(id) || scheduleNames.has(id)) return true;
568
640
  const owner = conversations.get(id)?.owner;
569
641
  return owner ? belongsToChat(owner.conversationId) : false;
570
642
  }
571
- for (const conversation of data.conversations.toSorted(compareConversations)) {
572
- const key = conversation.owner ? `task-${conversation.owner.taskId}` : "root";
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) => {
573
648
  if (!children.has(key)) children.set(key, []);
574
- children.get(key).push({ type: "conversation", ...conversation });
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}`;
575
667
  }
576
668
  for (const task of data.tasks) {
577
- const key = task.owner === undefined ? `conversation-${task.conversationId}` : `task-${task.owner}`;
578
- if (!children.has(key)) children.set(key, []);
579
- children.get(key).push({ type: "task", ...task });
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;
580
722
  }
581
723
  function renderNode(node) {
582
724
  const item = element("li");
583
725
  const key = `${node.type}-${node.id}`;
584
726
  item.id = `tree-${key}`;
585
727
  if (node.type === "conversation") {
586
- const heading = element("span", `Conversation #${node.id}`);
587
- const name = conversationName(node.id);
588
- if (name) heading.append(element("span", ` / ${name} `, "meta"));
589
- if (chatNames.has(node.id)) {
590
- heading.append(link("Chat", `/chat?conversationId=${node.id}`, "meta"));
591
- }
592
- item.append(heading);
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);
593
745
  } else {
594
- const detail = element("details");
746
+ item.className = node.kind === "pi.user" ? "entry turn" : "entry";
747
+ const detail = lazyDetails(entrySummary(node), `/tasks/entry/data?entryId=${node.id}`);
595
748
  detail.id = key;
596
- const summary = element("summary", `${node.kind}${node.name ? `: ${node.name}` : ""} #${node.id}`);
597
- const status = [node.status, node.phase, node.outcome].filter(Boolean).join(" / ");
598
- const waiting = node.on?.length ? ` on [${node.on.join(", ")}]` : "";
599
- const flags = `${node.background ? " [background]" : ""}${node.abortRequested ? " [abort requested]" : ""}`;
600
- summary.append(element("span", ` ${status}${waiting}${flags}`, "meta"));
601
- const content = element("pre", "Loading latest state...");
602
- detail.append(summary, content);
603
- let loaded = false;
604
- detail.addEventListener("toggle", async () => {
605
- if (!detail.open || loaded) return;
606
- loaded = true;
607
- try {
608
- const task = await fetchData(`/tasks/task/data?taskId=${node.id}`);
609
- content.textContent = JSON.stringify(task, null, 2);
610
- } catch (error) {
611
- loaded = false;
612
- content.textContent = error.message;
613
- }
614
- });
615
749
  item.append(detail);
616
750
  }
617
751
  const descendants = children.get(key) || [];
618
- if (descendants.length) {
752
+ const more = node.type === "conversation" && data.more?.[node.id];
753
+ if (descendants.length || more) {
619
754
  const list = element("ul", undefined, "task-tree");
620
755
  for (const child of descendants) list.append(renderNode(child));
621
- item.append(list);
756
+ if (more) list.append(element("li", "older turns not listed", "meta"));
757
+ (node.type === "conversation" && node.owner ? item.firstChild : item).append(list);
622
758
  }
623
759
  return item;
624
760
  }
625
761
  const roots = conversationId
626
- ? data.conversations
627
- .filter((conversation) => String(conversation.id) === conversationId)
628
- .map((conversation) => ({ type: "conversation", ...conversation }))
629
- : children.get("root") || [];
762
+ ? data.conversations.filter((conversation) => String(conversation.id) === conversationId)
763
+ : data.conversations.filter((conversation) => !conversation.owner);
630
764
  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
+ );
631
773
  if (!visible.length) view.append(element("p", "No conversations.", "empty"));
632
774
  for (const conversation of visible.toSorted(compareConversations)) {
633
775
  const card = element("section", undefined, "card");
634
776
  card.id = `tasks-${conversation.id}`;
635
777
  const list = element("ul", undefined, "task-tree task-root");
636
- list.append(renderNode(conversation));
778
+ list.append(renderNode({ type: "conversation", ...conversation }));
637
779
  card.append(list);
638
780
  view.append(card);
639
781
  }
@@ -643,7 +785,7 @@ function setupToolbar() {
643
785
  if (page === "/chat" || page === "/tasks") {
644
786
  if (conversationId) {
645
787
  toolbar.className = "toolbar";
646
- toolbar.append(page === "/chat" ? link("< Recent Chats", "/") : link("< All conversations", "/tasks"));
788
+ toolbar.append(page === "/chat" ? link("< Messages", "/") : link("< All conversations", "/tasks"));
647
789
  }
648
790
  } else if (page === "/memory") {
649
791
  const records = Array.isArray(data) ? memoryRows() : data.records;
@@ -702,6 +844,7 @@ async function render() {
702
844
  if (page === "/chat") await loadChats();
703
845
  else if (page === "/memory") renderMemory();
704
846
  else if (page === "/settings") renderSettings();
847
+ else if (page === "/chats") renderChatSettings();
705
848
  else if (page === "/logs") renderLogs();
706
849
  else if (page === "/tasks") renderTasks();
707
850
  else if (page === "/scheduled") renderScheduled();
package/dist/web/page.js CHANGED
@@ -5,7 +5,8 @@ function escapeHtml(text) {
5
5
  export function renderPage(page, state) {
6
6
  const active = page === "/" || page === "/chat" ? "/" : page;
7
7
  const navigation = [
8
- ["/", "Recent Chats"],
8
+ ["/", "Messages"],
9
+ ["/chats", "Chats"],
9
10
  ["/tasks", "Tasks"],
10
11
  ["/scheduled", "Scheduled"],
11
12
  ["/logs", "Logs"],
@@ -19,7 +20,7 @@ export function renderPage(page, state) {
19
20
  <title>blue — pi-imessage</title><link rel="stylesheet" href="/assets/style.css">
20
21
  <script src="/assets/app.js" defer></script></head>
21
22
  <body><nav class="app-nav" aria-label="Main navigation">${navigation}</nav>
22
- <main class="content" id="main"><header class="page-header"><h1 id="title">Recent Chats</h1>
23
+ <main class="content" id="main"><header class="page-header"><h1 id="title">Messages</h1>
23
24
  <div class="connection"><button id="refresh" type="button">Refresh</button></div></header>
24
25
  <div id="toolbar"></div><p id="notice" role="status" hidden></p><div id="view" aria-busy="true"></div>
25
26
  <details class="native-state"><summary>Native state</summary><pre id="state">${escapeHtml(state)}</pre></details></main>
@@ -11,18 +11,9 @@ export function readSchedules(harness) {
11
11
  const task = await tx.task(schedule.taskId);
12
12
  const checkpoint = task?.state.checkpoint;
13
13
  const state = checkpoint && typeof checkpoint === "object" && !Array.isArray(checkpoint) ? checkpoint : undefined;
14
- const executions = [];
15
- let cursor;
16
- do {
17
- const page = await tx.scanTasks({ conversationId: schedule.conversationId, kind: EXECUTION_KIND }, 100, cursor);
18
- executions.push(...page.items);
19
- cursor = page.next;
20
- } while (cursor);
21
- // Native scans merge live/terminal records; their order is not execution chronology.
22
- const runs = executions
23
- .sort((a, b) => b.id - a.id)
24
- .slice(0, 10)
25
- .map((run) => {
14
+ // Occurrence IDs grow with admission time; the newest ten are the history shown.
15
+ const executions = await tx.scanTasks({ conversationId: schedule.conversationId, kind: EXECUTION_KIND, order: "descending" }, 10);
16
+ const runs = executions.items.map((run) => {
26
17
  const outcome = run.state.outcome;
27
18
  const result = outcome?.status === "completed" &&
28
19
  outcome.result &&
@@ -38,9 +29,7 @@ export function readSchedules(harness) {
38
29
  input: run.input,
39
30
  status: outcome?.status ?? run.state.status,
40
31
  result,
41
- error: outcome?.status === "failed" || outcome?.status === "faulted"
42
- ? outcome.error.message
43
- : undefined,
32
+ error: outcome?.status === "failed" || outcome?.status === "faulted" ? outcome.error.message : undefined,
44
33
  delivery: receipt?.textStatus ?? (result?.requestId ? "queued" : undefined),
45
34
  };
46
35
  });