@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.
@@ -6,12 +6,12 @@ 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 } from "../agent/chats.js";
9
+ import { Chats, 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";
13
13
  import { Schedules } from "../agent/scheduling.js";
14
- import { readSettings } from "../config/settings.js";
14
+ import { isReplyEnabled, readSettings } from "../config/settings.js";
15
15
  import { displayHistory, historyActivity } from "./history.js";
16
16
  import { memoryPage, parseMemoryFile } from "./memory.js";
17
17
  import { renderPage } from "./page.js";
@@ -113,6 +113,36 @@ 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. */
117
+ async function chatSettings() {
118
+ const [chats, configuration] = await Promise.all([
119
+ options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
120
+ settings(),
121
+ ]);
122
+ const records = chats?.items ?? [];
123
+ const rows = await Promise.all(records.map(async (chat) => {
124
+ const conversation = await options.agent.harness.conversation(chat.conversationId, BACKGROUND_CONTEXT);
125
+ const [agent, entries] = conversation
126
+ ? await Promise.all([conversation.agent(BACKGROUND_CONTEXT), history(chat.conversationId)])
127
+ : [undefined, undefined];
128
+ return {
129
+ chatGuid: chat.chatGuid,
130
+ conversationId: chat.conversationId,
131
+ groupName: entries ? displayHistory(entries, 15).groupName : undefined,
132
+ updatedAt: entries ? historyActivity(entries) : undefined,
133
+ replies: isReplyEnabled(configuration, chat.chatGuid),
134
+ verbose: { chat: chat.progress, effective: progressEnabled(configuration, records, chat.chatGuid) },
135
+ model: agent?.model ? `${agent.model.provider}/${agent.model.modelId}` : undefined,
136
+ thinking: agent?.thinkingLevel,
137
+ };
138
+ }));
139
+ rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || Number(b.conversationId) - Number(a.conversationId));
140
+ return {
141
+ chats: rows,
142
+ defaults: { progressMessages: configuration.progressMessages },
143
+ revision: viewRevision(),
144
+ };
145
+ }
116
146
  const server = createServer((request, response) => {
117
147
  void handle(request, response).catch((error) => {
118
148
  if (!response.headersSent)
@@ -241,16 +271,31 @@ export async function startWeb(options) {
241
271
  value = await readSchedules(options.agent.harness);
242
272
  compact = true;
243
273
  }
274
+ else if (page === "/chats") {
275
+ value = await chatSettings();
276
+ compact = true;
277
+ }
244
278
  else if (page === "/tasks") {
245
279
  value = {
246
280
  ...(await readTaskTree(options.agent.harness)),
247
281
  schedules: await options.agent.harness.snapshot(Schedules, BACKGROUND_CONTEXT),
248
282
  chats: await options.agent.harness.snapshot(Chats, BACKGROUND_CONTEXT),
249
283
  sessions: await options.agent.harness.snapshot(Sessions, BACKGROUND_CONTEXT),
284
+ session: join(options.workingDir, "durable"),
250
285
  revision: viewRevision(),
251
286
  };
252
287
  compact = true;
253
288
  }
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
+ }
254
299
  else if (url.pathname === "/tasks/task/data") {
255
300
  const id = url.searchParams.get("taskId");
256
301
  if (!id || !/^\d+$/.test(id) || !Number.isSafeInteger(Number(id)))
@@ -265,7 +310,7 @@ export async function startWeb(options) {
265
310
  value = await settings();
266
311
  else if (page === "/logs") {
267
312
  const cursor = readCursor(url.searchParams.get("cursor"));
268
- const tasks = await options.agent.harness.commit((tx) => tx.scanTasks({}, 100, cursor), BACKGROUND_CONTEXT);
313
+ const tasks = await options.agent.harness.commit((tx) => tx.scanTasks(cursor ? {} : { order: "descending" }, 100, cursor), BACKGROUND_CONTEXT);
269
314
  let hostLog = "";
270
315
  try {
271
316
  const file = await open(join(options.workingDir, "service.log"), "r");
@@ -439,6 +439,12 @@ 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
+
442
448
  .schedule-row {
443
449
  background: var(--surface);
444
450
  margin-bottom: 6px;
@@ -541,3 +547,37 @@ body.page-logs .native-state {
541
547
  width: 100px;
542
548
  }
543
549
  }
550
+
551
+ .chat-table th,
552
+ .chat-table td {
553
+ padding: 8px 16px;
554
+ border-bottom: 1px solid var(--border);
555
+ }
556
+
557
+ .chat-table th {
558
+ opacity: 0.55;
559
+ white-space: nowrap;
560
+ }
561
+
562
+ .chat-table td:first-child {
563
+ width: 100%;
564
+ word-break: break-all;
565
+ }
566
+
567
+ .chat-table tbody tr:last-child td {
568
+ border-bottom: 0;
569
+ }
570
+
571
+ .chat-silent {
572
+ opacity: 0.55;
573
+ }
574
+
575
+ .chat-empty {
576
+ padding: 12px 16px;
577
+ }
578
+
579
+ /* The Chats tables grow with their rows; the page itself scrolls. */
580
+ body.page-chats .card {
581
+ max-height: none;
582
+ overflow: visible;
583
+ }
package/dist/web/tasks.js CHANGED
@@ -1,63 +1,205 @@
1
1
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { UserEntry, } from "@earendil-works/pi-durable";
2
3
  import { displayHistory, historyActivity } from "./history.js";
3
- /** Read one consistent tree, including terminal receipts and task-owned conversations. */
4
+ /** Newest generations shown per conversation, with every entry and task from the turn of the oldest one. */
5
+ export const RECENT_GENERATIONS_PER_CONVERSATION = 10;
6
+ /** Newest tasks shown for a conversation that never generated (schedulers). */
7
+ export const RECENT_TASKS_PER_CONVERSATION = 20;
8
+ /** Pages of 5 entries read back from the oldest generation to find the user message that opened its turn. */
9
+ const TURN_LOOKBACK_PAGES = 4;
10
+ function firstLine(text, limit = 80) {
11
+ return text
12
+ .replace(/^\[(?:Group '[^']*'|DM|SMS) from [^\]]+\] /, "")
13
+ .split("\n")
14
+ .find((line) => line.trim())
15
+ ?.trim()
16
+ .slice(0, limit);
17
+ }
18
+ /** One line per entry: the first user or assistant message, tool calls by name only, tool results by text. */
19
+ function summarizeEntry(entry) {
20
+ const message = entry.model?.[0];
21
+ if (!message)
22
+ return { role: undefined, text: undefined };
23
+ const content = message.content;
24
+ const text = typeof content === "string"
25
+ ? content
26
+ : content
27
+ .map((block) => block.type === "text"
28
+ ? block.text
29
+ : block.type === "toolCall"
30
+ ? `[toolCall ${block.name}]`
31
+ : block.type === "thinking"
32
+ ? "[thinking]"
33
+ : `[${block.type}]`)
34
+ .filter(Boolean)
35
+ .join(" ");
36
+ return { role: message.role, text: firstLine(text) };
37
+ }
38
+ /**
39
+ * Read one consistent tree, newest first: all conversations, every unfinished task, every task that owns a
40
+ * conversation and their parent chains. Per conversation the window is the newest generations plus every
41
+ * entry and task back to the `pi.user` entry that opened the oldest one's turn. Entries and tasks share one
42
+ * id sequence, so the window is a plain id range and the browser nests records by `owner` and `byTaskId`
43
+ * without any inference. Descending scans stop at the window instead of reading the whole history
44
+ * (earendil-works/pi#10546).
45
+ */
4
46
  export function readTaskTree(harness) {
5
47
  return harness.commit(async (tx) => {
6
48
  const conversations = [];
49
+ const kept = new Map();
50
+ const entries = [];
51
+ const more = {};
7
52
  let cursor;
8
53
  do {
9
54
  const page = await tx.scanConversations({}, 100, cursor);
10
55
  for (const conversation of page.items) {
11
- const history = await tx.scanEntries({ conversationId: conversation.id }, 100);
12
- const { groupName } = displayHistory(history, 1);
56
+ const id = conversation.id;
57
+ // One descending scan: the newest tasks down to the oldest shown generation; a task scan with a
58
+ // conversation filter walks the global task order, so it must not run twice per conversation.
59
+ const recent = [];
60
+ let oldest;
61
+ let generations = 0;
62
+ let next;
63
+ scan: do {
64
+ const tasks = await tx.scanTasks({ conversationId: id, order: "descending" }, 100, next);
65
+ for (const task of tasks.items) {
66
+ if (task.kind === "pi.generation") {
67
+ if (++generations > RECENT_GENERATIONS_PER_CONVERSATION) {
68
+ more[id] = true;
69
+ break scan;
70
+ }
71
+ oldest = task;
72
+ }
73
+ else if (!generations && recent.length >= RECENT_TASKS_PER_CONVERSATION) {
74
+ more[id] = true;
75
+ break scan;
76
+ }
77
+ recent.push(task);
78
+ }
79
+ next = tasks.next;
80
+ } while (next);
81
+ let history;
82
+ let floor;
83
+ if (oldest) {
84
+ history = [];
85
+ let next;
86
+ do {
87
+ const window = await tx.scanEntries({ conversationId: id, minEntryId: oldest.id }, 100, next);
88
+ history.push(...window.items);
89
+ next = window.next;
90
+ } while (next);
91
+ // Walk back to the user message that opened the oldest generation's turn.
92
+ let max = oldest.id - 1;
93
+ for (let pages = 0; pages < TURN_LOOKBACK_PAGES; pages++) {
94
+ const older = await tx.scanEntries({ conversationId: id, maxEntryId: max }, 5);
95
+ const user = older.items.findIndex((entry) => UserEntry.is(entry));
96
+ history.push(...(user === -1 ? older.items : older.items.slice(0, user + 1)));
97
+ if (user !== -1 || !older.items.length)
98
+ break;
99
+ max = older.items[older.items.length - 1].id - 1;
100
+ }
101
+ const turn = history.find((entry) => UserEntry.is(entry) && entry.id < oldest.id);
102
+ floor = turn ? turn.id : oldest.id;
103
+ history = history.filter((entry) => entry.id >= floor);
104
+ // Tasks between the turn's user message and its first generation sit on the next page.
105
+ while (next && (recent.at(-1)?.id ?? 0) >= floor) {
106
+ const tasks = await tx.scanTasks({ conversationId: id, order: "descending" }, 100, next);
107
+ recent.push(...tasks.items.filter((task) => task.id >= floor));
108
+ next = tasks.next;
109
+ }
110
+ }
111
+ else {
112
+ // Names and activity still come from the newest 100 entries, as on the Messages page.
113
+ history = [...(await tx.scanEntries({ conversationId: id }, 100)).items];
114
+ floor = history[RECENT_TASKS_PER_CONVERSATION - 1]?.id ?? 0;
115
+ }
116
+ const page = { items: history, next: undefined };
117
+ const { groupName } = displayHistory(page, 1);
13
118
  conversations.push({
14
119
  ...conversation,
15
- updatedAt: historyActivity(history),
120
+ updatedAt: historyActivity(page),
16
121
  groupName: typeof groupName === "string" ? groupName : undefined,
17
122
  });
123
+ for (const entry of history) {
124
+ if (entry.id < floor)
125
+ continue;
126
+ entries.push({
127
+ id: entry.id,
128
+ conversationId: id,
129
+ kind: entry.kind,
130
+ byTaskId: entry.byTaskId,
131
+ ...summarizeEntry(entry),
132
+ });
133
+ }
134
+ for (const task of recent)
135
+ if (!oldest || task.id >= floor)
136
+ kept.set(task.id, task);
137
+ if (conversation.owner && !kept.has(conversation.owner.taskId)) {
138
+ const owner = await tx.task(conversation.owner.taskId);
139
+ if (owner)
140
+ kept.set(owner.id, owner);
141
+ }
18
142
  }
19
143
  cursor = page.next;
20
144
  } while (cursor);
21
- const tasks = [];
145
+ // Unfinished tasks can be old (sleeping schedulers); status scans are small.
146
+ for (const status of ["pending", "running", "waiting", "sleeping"]) {
147
+ cursor = undefined;
148
+ do {
149
+ const page = await tx.scanTasks({ status, order: "descending" }, 100, cursor);
150
+ for (const task of page.items)
151
+ kept.set(task.id, task);
152
+ cursor = page.next;
153
+ } while (cursor);
154
+ }
155
+ // A kept child needs its parent chain so the tree can place it.
156
+ for (const task of [...kept.values()]) {
157
+ for (let owner = task.owner; owner !== undefined && !kept.has(owner); owner = kept.get(owner)?.owner) {
158
+ const parent = await tx.task(owner);
159
+ if (!parent)
160
+ break;
161
+ kept.set(parent.id, parent);
162
+ }
163
+ }
22
164
  const assistants = new Map();
23
- do {
24
- const page = await tx.scanTasks({}, 100, cursor);
25
- for (const task of page.items) {
26
- let name;
27
- const input = task.input;
28
- if (task.kind === "pi.tool" && input && typeof input === "object" && !Array.isArray(input)) {
29
- if (typeof input.assistant === "number" && typeof input.callId === "string") {
30
- const id = input.assistant;
31
- if (!assistants.has(id))
32
- assistants.set(id, await tx.entry(id));
33
- for (const message of assistants.get(id)?.model ?? []) {
34
- if (message.role !== "assistant")
35
- continue;
36
- const call = message.content.find((block) => block.type === "toolCall" && block.id === input.callId);
37
- if (call?.type === "toolCall")
38
- name = call.name;
39
- }
165
+ const tasks = [];
166
+ for (const task of [...kept.values()].sort((a, b) => Number(b.id) - Number(a.id))) {
167
+ let name;
168
+ const input = task.input;
169
+ if (task.kind === "pi.tool" && input && typeof input === "object" && !Array.isArray(input)) {
170
+ const call = input;
171
+ if (typeof call.assistant === "number" && typeof call.callId === "string") {
172
+ const id = call.assistant;
173
+ if (!assistants.has(id))
174
+ assistants.set(id, await tx.entry(id));
175
+ for (const message of assistants.get(id)?.model ?? []) {
176
+ if (message.role !== "assistant")
177
+ continue;
178
+ const block = message.content.find((item) => item.type === "toolCall" && item.id === call.callId);
179
+ if (block?.type === "toolCall")
180
+ name = block.name;
40
181
  }
41
182
  }
42
- const state = task.state;
43
- tasks.push({
44
- id: task.id,
45
- conversationId: task.conversationId,
46
- owner: task.owner,
47
- kind: task.kind,
48
- name,
49
- background: task.background,
50
- abortRequested: task.abortRequested,
51
- status: state.status,
52
- phase: state.checkpoint && typeof state.checkpoint === "object" && !Array.isArray(state.checkpoint)
53
- ? state.checkpoint.phase
54
- : undefined,
55
- on: state.status === "waiting" ? state.on : undefined,
56
- outcome: state.outcome?.status,
57
- });
58
183
  }
59
- cursor = page.next;
60
- } while (cursor);
61
- return { conversations, tasks };
184
+ const state = task.state;
185
+ tasks.push({
186
+ id: task.id,
187
+ conversationId: task.conversationId,
188
+ owner: task.owner,
189
+ kind: task.kind,
190
+ name,
191
+ background: task.background,
192
+ abortRequested: task.abortRequested,
193
+ status: state.status,
194
+ // Phases exist only while a task runs; a terminal record keeps its outcome and drops the checkpoint.
195
+ phase: state.checkpoint && typeof state.checkpoint === "object" && !Array.isArray(state.checkpoint)
196
+ ? state.checkpoint.phase
197
+ : undefined,
198
+ on: state.status === "waiting" ? state.on : undefined,
199
+ until: state.status === "sleeping" ? state.until : undefined,
200
+ outcome: state.outcome?.status,
201
+ });
202
+ }
203
+ return { conversations, tasks, entries, more };
62
204
  }, BACKGROUND_CONTEXT);
63
205
  }
@@ -0,0 +1,11 @@
1
+ # 0048. Quiet attachment reads
2
+
3
+ Status: proposed (owner-requested behavior in #38, 2026-10-07)
4
+
5
+ **Problem.** Reading an archived attachment adds noisy tool notices containing long private archive paths.
6
+
7
+ **Example.** A native `read` of `WORKING_DIR/attachments/<chat>/<file>` produces no request or completion notice. An ordinary file read still shows the existing progress pair.
8
+
9
+ **Decision.** Add a narrow exception to ADR 0040's all-tool progress rule: suppress both admission and settlement notices for native `read` calls under the active workspace attachment root, including owned child calls and failed reads. Normalize relative, home, @ and file-URL paths and resolve filesystem aliases using metadata only. Match directory boundaries, not a generic attachment substring. Other reads, tools, task progress, manual compaction and final replies remain unchanged.
10
+
11
+ The existing native observer filters before assigning notice routing or making direct-send receipts. Execution, result/error history, receipt/no-replay rules and the single pipeline are unchanged. No new poller, sender, permission boundary, data removal or credential policy is introduced.
@@ -0,0 +1,21 @@
1
+ # 0049. Per-chat progress notices
2
+
3
+ Status: proposed (owner-requested implementation, 2026-10-24)
4
+
5
+ **Problem.** `progressMessages` in `settings.json` turns tool progress notices on or off for every chat at once. A busy group wants only the final messages while an admin chat wants to watch the tools.
6
+
7
+ **Example.**
8
+
9
+ ```text
10
+ /verbose -> Verbose: on (default)
11
+ /verbose off -> Verbose: off (this chat only)
12
+ /verbose default -> Verbose: on (default)
13
+ ```
14
+
15
+ **Decision.**
16
+
17
+ - Each chat record in the native `imessage.chats` document gets an optional `progress` boolean. `/verbose on|off` sets it and `/verbose default` removes it; `/verbose` alone reports the effective state. The record version is unchanged; old records have no field.
18
+ - The progress observer (ADR 0040) resolves the owning chat first and then gates both the admission and the settlement notice by `chat.progress ?? settings.progressMessages`. Reply-enabled allowlisting, formatting, screening, receipts and no-replay rules are unchanged.
19
+ - `settings.json` stays the default and is never rewritten by a command; the per-chat choice lives only in Durable.
20
+
21
+ **Consequences.** A `/verbose off` chat still runs tools and records their results; it only stops seeing the two notice lines. Child notices follow the owning chat's choice. Changing the global default does not touch chats with an explicit choice.
@@ -0,0 +1,53 @@
1
+ # 0052. The host owns its own restart
2
+
3
+ Status: accepted (owner accepted on 2026-10-24)
4
+
5
+ **Problem.** A chat assistant restarts the service by writing a controller
6
+ script and running it from a `bash` tool call. The host cannot tell that this
7
+ call is about to stop it. When the call is still running at the stop, ADR 0041
8
+ reruns it on the next start, which launches another controller and stops the
9
+ service again (#39: about 70 restarts in eleven minutes). A host-side stop
10
+ heuristic that aborts a tool interrupted twice in a row was tried the same
11
+ day; it limits the damage after the fact, does not remove the cause, and
12
+ aborts any long tool that two deployments happen to interrupt in a row.
13
+
14
+ **Example.**
15
+
16
+ ```text
17
+ restart_service({ release: "d627232…" })
18
+ -> Restart into /Users/clawbot/pi-imessage-releases/d627232… is scheduled;
19
+ the service stops after this call settles. Controller directory:
20
+ /tmp/pi-imessage-restart.Xy12ab. Verify in a later call.
21
+ ```
22
+
23
+ The call settles, the host opens the controller in Terminal, the controller
24
+ stops the old process and becomes the new one. The next turn verifies the
25
+ listener, the log and the running release directory.
26
+
27
+ **Decision.**
28
+
29
+ - A built-in `restart_service` tool takes the full commit hash of a prepared
30
+ release directory, a sibling of the running one. It refuses a hash that is not
31
+ a directory with `src/cli.ts`, `node_modules` and `ops/restart.command`, and
32
+ a second request while one is scheduled.
33
+ - The tool writes the private controller directory of ops/README.md step 3
34
+ itself: `environment.sh` from the process environment, `control.sh` from the
35
+ process's own PID, start time and command line, and a copy of
36
+ `ops/restart.command` from the target release. The controller deletes
37
+ `environment.sh` after sourcing it.
38
+ - The tool returns at once. The host launches the controller only after the
39
+ tool task has settled, so the stop can never interrupt the call that asked
40
+ for it.
41
+ - The tool is `replay: "unsafe"`. If it is interrupted anyway, the model gets
42
+ an interrupted result and checks what is running instead of a rerun
43
+ launching a second controller.
44
+ - Ordinary tools keep the rerun default of ADR 0041. Operators keep the manual
45
+ procedure for a cold start when no service is running.
46
+
47
+ **Consequences.** A self-restart costs one restart and no extra model turn.
48
+ The stop still happens at a tool boundary of the requesting chat, so that chat's
49
+ next model request is repeated after the restart, as ADR 0041 accepts. The
50
+ restart loop of #39 cannot recur through this tool, so the stop heuristic is
51
+ removed and ADR 0041 applies unchanged to every other tool. A chat that writes
52
+ its own controller through `bash` is still subject to the ops rule of ending
53
+ the call right after `open`.
@@ -0,0 +1,44 @@
1
+ # 0053. Service-log errors for failures that break normal operation
2
+
3
+ Status: proposed
4
+
5
+ **Problem.** Durable records every task outcome, but only in its storage.
6
+ `service.log` shows startup, sends and a few delivery warnings. On 2026-10-25
7
+ the nightly memory reflection failed three times because the model child had
8
+ exhausted a provider quota; the process, the port and the root HTTP check
9
+ looked healthy and `service.log` had no line about it. The failure was only
10
+ visible by reading `durable/main.jsonl` or by a chat notice that was deduped.
11
+
12
+ **Example.**
13
+
14
+ ```text
15
+ 2026-10-25T00:14:48.924Z Model call failed {
16
+ kind: 'pi.generation', task: 183434, conversation: 183426, status: 'failed',
17
+ provider: 'openai-codex', model: 'gpt-6.1-sol',
18
+ reason: 'You have hit your ChatGPT usage limit (plus plan). Try again in ~291 min.'
19
+ }
20
+ ```
21
+
22
+ **Decision.**
23
+
24
+ - `service.log` gets a `console.error` line with an ISO timestamp for a failure
25
+ that breaks normal operation. A failure qualifies when either holds: a chat
26
+ or scheduled job does not get the result it was waiting for, or the system
27
+ enters a state that needs an operator. Transient errors that recover on
28
+ their own, such as a retried provider error or a delivery whose status is
29
+ later confirmed, do not qualify.
30
+ - One module, `src/agent/error-log.ts`, owns these lines. It observes Durable
31
+ commits synchronously and never calls Session APIs. Each error kind is one
32
+ branch with its own message name; adding a kind states which of the two
33
+ conditions it meets.
34
+ - The first kind is a model call that ends `failed` or `faulted`: the line
35
+ carries the task, conversation, provider, model and the reason truncated to
36
+ 200 characters. It carries no prompt, tool argument or input content.
37
+ - Other task kinds, including scheduled jobs that fault, are not logged yet;
38
+ they are added under the same conditions when needed.
39
+
40
+ **Consequences.** A quota, auth or provider outage is visible in the service
41
+ log within the same commit that records it, so a health check that reads the
42
+ log catches it without a paid model probe. The web `/logs` page shows the same
43
+ lines. Durable records remain the full source of truth; the log is a pointer
44
+ to them, not a replacement.
@@ -23,7 +23,7 @@ Accepted ADR files are never edited; this index records which ones are current.
23
23
  | [0018](0018-tasks-without-prompt-api.md) | Ordinary `/stop`; no HTTP `/prompt` or `/send` | Active |
24
24
  | [0036](0036-workspace-policy-and-neutral-service-id.md) | Workspace policy and neutral service ID | Active |
25
25
  | [0040](0040-chat-progress-notices.md) | Chat progress notices | Active; consolidates 0020–0028, 0031 |
26
- | [0041](0041-restart-and-recovery.md) | Restart and recovery | Active; consolidates 0037–0039 |
26
+ | [0041](0041-restart-and-recovery.md) | Restart and recovery | Active; consolidates 0037–0039; self-restart path added by 0052 |
27
27
  | [0042](0042-background-schedules-and-workspace-extensions.md) | Background schedules and workspace extensions | Active; operator-run clause replaced by 0047 |
28
28
  | [0043](0043-remove-legacy-migration.md) | Remove the legacy migration code | Active; archived at tag `archive/legacy-migration` |
29
29
  | [0044](0044-message-channels.md) | Organize message sources as channels | Proposed |
@@ -31,6 +31,10 @@ Accepted ADR files are never edited; this index records which ones are current.
31
31
 
32
32
  | [0046](0046-selective-command-redaction.md) | Selective command-preview redaction | Proposed; owner-requested implementation in #36 |
33
33
  | [0047](0047-one-scheduling-path.md) | One scheduling path for recurring and one-off work | Active |
34
+ | [0048](0048-quiet-attachment-reads.md) | Quiet attachment reads | Proposed; owner-requested implementation in #38 |
35
+ | [0049](0049-per-chat-progress-notices.md) | Per-chat progress notices via `/verbose` | Proposed; owner-requested implementation |
36
+ | [0052](0052-host-owned-self-restart.md) | The host owns its own restart (`restart_service`) | Active; owner accepted on 2026-10-24 (#39) |
37
+ | [0053](0053-service-log-errors.md) | Service-log errors for failures that break normal operation | Proposed; first kind (model call failed) implemented |
34
38
 
35
39
  ## Replaced or rejected
36
40
 
@@ -47,4 +51,5 @@ Accepted ADR files are never edited; this index records which ones are current.
47
51
  | [0032](0032-native-background-schedules.md)–[0035](0035-external-business-task-extensions.md) | Native schedules, operator run, local time, workspace extensions | 0042 |
48
52
  | [0037](0037-restart-during-in-flight-work.md)–[0039](0039-rerun-interrupted-tools.md) | Restart and tool rerun | 0041 |
49
53
 
50
- Number 0029 was never used.
54
+ Numbers 0029, 0050 and 0051 are not used: 0050 and 0051 were interim stop
55
+ heuristics for #39 that the owner did not accept; 0052 replaced them.
package/docs/api.md CHANGED
@@ -5,17 +5,27 @@
5
5
  Default address: `http://localhost:7750` (configured by `WEB_HOST` and `WEB_PORT`).
6
6
  The server has no authentication; do not expose it to untrusted networks.
7
7
 
8
- The pages `/`, `/chat`, `/tasks`, `/scheduled`, `/settings`, `/memory` and `/logs` are read-only.
8
+ The pages `/`, `/chat`, `/chats`, `/tasks`, `/scheduled`, `/settings`, `/memory` and `/logs` are read-only.
9
+ The Chats tab (`/chats`, `/chats/data`) lists groups and direct chats, one row
10
+ each: reply eligibility, the `/verbose` choice with its effective value, the
11
+ conversation's model and thinking level, and last activity. Chats that do not
12
+ reply are dimmed. It never shows message text. The message history page in the
13
+ navigation is labelled Messages.
9
14
  The Tasks tab shows real chat and native schedule ownership trees, including
10
15
  subagent child conversations and terminal tasks. Internal-only conversations such as model-health
11
16
  are hidden, including when selected by URL; their stored data is not deleted.
12
17
  `/tasks/data` retains all conversation records for native diagnostics
13
- and lightweight task summaries, reading every native scan page. Input and payloads
18
+ and lightweight task summaries, newest first. It lists every unfinished task,
19
+ every task that owns a conversation, their parent chains and the 20 newest
20
+ tasks per conversation, read with descending scans so the full task history is
21
+ not scanned; a conversation with older tasks is flagged in `more` and shows one
22
+ line. The Scheduled history and the Logs task list use the same descending
23
+ order, so "Older tasks" pages go back in time. Input and payloads
14
24
  are fetched only when a task is expanded, through `/tasks/task/data?taskId=ID`.
15
25
  `/tasks?conversationId=ID` focuses one conversation and its descendants. The tree
16
26
  and expanded tasks update only on manual refresh. Conversation lists and sidebars
17
27
  use newest message activity first, with conversation ID as a descending tie-break;
18
- Tasks uses the same activity clock as Recent Chats, not conversation creation order.
28
+ Tasks uses the same activity clock as Messages, not conversation creation order.
19
29
  Conversation windows have a maximum height of `min(560px, 70dvh)` and scroll inside. These are latest task states,
20
30
  not checkpoint history: terminal tasks retain input and outcome, but no checkpoint.
21
31
  Existing redaction and large-string truncation apply to task details too.
@@ -89,4 +99,6 @@ previews. Raw outputs, commands, prompts and document contents stay local.
89
99
  Known credential indicators are redacted; arbitrary user-authored secrets cannot
90
100
  be guaranteed detectable. Missing or false keeps final-only delivery. The setting
91
101
  is reread for each notice; old tasks and unknown sends are not replayed
92
- ([ADR 0020](adr/0020-chat-task-progress.md)).
102
+ ([ADR 0040](adr/0040-chat-progress-notices.md)). `/verbose on|off` in a chat
103
+ overrides this default for that chat only and `/verbose default` removes the
104
+ override ([ADR 0049](adr/0049-per-chat-progress-notices.md)).