@kingcrab/pi-imessage 0.0.43 → 1.0.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 (187) hide show
  1. package/README.md +101 -127
  2. package/dist/agent/behavior.js +79 -0
  3. package/dist/agent/chats.js +67 -0
  4. package/dist/agent/commands.js +163 -0
  5. package/dist/agent/compaction.js +51 -0
  6. package/dist/agent/deliver.js +139 -0
  7. package/dist/agent/direct-send.js +68 -0
  8. package/dist/agent/harness.js +79 -0
  9. package/dist/agent/health.js +32 -0
  10. package/dist/agent/models.js +69 -0
  11. package/dist/agent/progress-format.js +87 -0
  12. package/dist/agent/progress.js +135 -0
  13. package/dist/agent/prompt.js +79 -0
  14. package/dist/agent/replies.js +9 -0
  15. package/dist/agent/run.js +159 -0
  16. package/dist/agent/scheduling.js +290 -0
  17. package/dist/agent/time.js +18 -0
  18. package/dist/cli.js +88 -191
  19. package/dist/config/settings.js +60 -0
  20. package/dist/extensions/browser.js +326 -0
  21. package/dist/extensions/final-text.js +23 -0
  22. package/dist/extensions/memory.js +109 -0
  23. package/dist/extensions/message.js +33 -0
  24. package/dist/extensions/read-image.js +72 -0
  25. package/dist/extensions/schedules.js +41 -0
  26. package/dist/extensions/subagent.js +54 -0
  27. package/dist/extensions/web.js +99 -0
  28. package/dist/extensions/workspace.js +98 -0
  29. package/dist/main.js +568 -88
  30. package/dist/transport/attachments.js +37 -0
  31. package/dist/transport/http.js +110 -0
  32. package/dist/{rich-text.js → transport/rich-text.js} +25 -13
  33. package/dist/{send.js → transport/send.js} +13 -13
  34. package/dist/transport/watch.js +124 -0
  35. package/dist/web/app.js +748 -0
  36. package/dist/web/history.js +87 -0
  37. package/dist/web/memory.js +81 -0
  38. package/dist/web/page.js +28 -0
  39. package/dist/web/schedules.js +74 -0
  40. package/dist/web/server.js +348 -0
  41. package/dist/web/style.css +543 -0
  42. package/dist/web/tasks.js +63 -0
  43. package/docs/adr/0001-durable-owns-state.md +11 -0
  44. package/docs/adr/0002-module-layering.md +11 -0
  45. package/docs/adr/0003-replies-at-most-once.md +11 -0
  46. package/docs/adr/0004-native-extensions.md +11 -0
  47. package/docs/adr/0005-history-migration.md +11 -0
  48. package/docs/adr/0006-jsonl-storage.md +11 -0
  49. package/docs/adr/0007-images-by-path.md +11 -0
  50. package/docs/adr/0008-compaction-and-run.md +11 -0
  51. package/docs/adr/0009-one-shot-cutover.md +11 -0
  52. package/docs/adr/0010-test-budget.md +11 -0
  53. package/docs/adr/0011-reconcile-unsent-answers.md +24 -0
  54. package/docs/adr/0012-run-in-owned-conversation.md +22 -0
  55. package/docs/adr/0013-thin-adapters-around-durable.md +18 -0
  56. package/docs/adr/0014-ephemeral-private-storage.md +13 -0
  57. package/docs/adr/0015-ordinary-stop-keeps-native-inbox.md +21 -0
  58. package/docs/adr/0016-process-http-transport.md +21 -0
  59. package/docs/adr/0017-browser-cli-owned-scope.md +22 -0
  60. package/docs/adr/0018-tasks-without-prompt-api.md +19 -0
  61. package/docs/adr/0019-remove-scheduled-services.md +41 -0
  62. package/docs/adr/0020-chat-task-progress.md +41 -0
  63. package/docs/adr/0021-readable-progress-without-link-previews.md +33 -0
  64. package/docs/adr/0022-progress-admission-and-settlement.md +35 -0
  65. package/docs/adr/0023-retain-progress-type-prefixes.md +18 -0
  66. package/docs/adr/0024-status-marker-before-type-prefix.md +13 -0
  67. package/docs/adr/0025-reusable-progress-and-command-preview.md +49 -0
  68. package/docs/adr/0026-inline-command-only-requests.md +16 -0
  69. package/docs/adr/0027-command-preview-width.md +10 -0
  70. package/docs/adr/0028-command-preview-120.md +8 -0
  71. package/docs/adr/0030-shared-web-read-cache.md +33 -0
  72. package/docs/adr/0031-quiet-compaction-progress.md +32 -0
  73. package/docs/adr/0032-native-background-schedules.md +58 -0
  74. package/docs/adr/0033-operator-scheduled-run.md +23 -0
  75. package/docs/adr/0034-machine-local-schedule-time.md +36 -0
  76. package/docs/adr/0035-external-business-task-extensions.md +56 -0
  77. package/docs/adr/0036-workspace-policy-and-neutral-service-id.md +37 -0
  78. package/docs/adr/0037-restart-during-in-flight-work.md +30 -0
  79. package/docs/adr/0038-restart-any-time.md +25 -0
  80. package/docs/adr/0039-rerun-interrupted-tools.md +24 -0
  81. package/docs/adr/0040-chat-progress-notices.md +44 -0
  82. package/docs/adr/0041-restart-and-recovery.md +28 -0
  83. package/docs/adr/0042-background-schedules-and-workspace-extensions.md +44 -0
  84. package/docs/adr/0043-remove-legacy-migration.md +23 -0
  85. package/docs/adr/0044-message-channels.md +32 -0
  86. package/docs/adr/0045-chat-scheduling-tools.md +51 -0
  87. package/docs/adr/0046-selective-command-redaction.md +31 -0
  88. package/docs/adr/0047-one-scheduling-path.md +158 -0
  89. package/docs/adr/README.md +50 -0
  90. package/docs/api.md +92 -0
  91. package/docs/architecture.md +164 -0
  92. package/docs/browser.md +93 -0
  93. package/docs/memory.md +27 -0
  94. package/docs/screenshot.png +0 -0
  95. package/docs/workspace-extensions.md +133 -0
  96. package/examples/workspace-extensions/system-context/index.ts +69 -0
  97. package/examples/workspace-extensions/system-context/package.json +3 -0
  98. package/ops/README.md +267 -0
  99. package/ops/preflight.mjs +28 -0
  100. package/package.json +36 -37
  101. package/dist/agent.d.ts +0 -84
  102. package/dist/agent.d.ts.map +0 -1
  103. package/dist/agent.js +0 -780
  104. package/dist/agent.js.map +0 -1
  105. package/dist/cli.d.ts +0 -15
  106. package/dist/cli.d.ts.map +0 -1
  107. package/dist/cli.js.map +0 -1
  108. package/dist/imessage.d.ts +0 -33
  109. package/dist/imessage.d.ts.map +0 -1
  110. package/dist/imessage.js +0 -76
  111. package/dist/imessage.js.map +0 -1
  112. package/dist/logger.d.ts +0 -33
  113. package/dist/logger.d.ts.map +0 -1
  114. package/dist/logger.js +0 -104
  115. package/dist/logger.js.map +0 -1
  116. package/dist/main.d.ts +0 -5
  117. package/dist/main.d.ts.map +0 -1
  118. package/dist/main.js.map +0 -1
  119. package/dist/memory.d.ts +0 -53
  120. package/dist/memory.d.ts.map +0 -1
  121. package/dist/memory.js +0 -196
  122. package/dist/memory.js.map +0 -1
  123. package/dist/model-health.d.ts +0 -11
  124. package/dist/model-health.d.ts.map +0 -1
  125. package/dist/model-health.js +0 -69
  126. package/dist/model-health.js.map +0 -1
  127. package/dist/pipeline.d.ts +0 -27
  128. package/dist/pipeline.d.ts.map +0 -1
  129. package/dist/pipeline.js +0 -59
  130. package/dist/pipeline.js.map +0 -1
  131. package/dist/queue.d.ts +0 -23
  132. package/dist/queue.d.ts.map +0 -1
  133. package/dist/queue.js +0 -89
  134. package/dist/queue.js.map +0 -1
  135. package/dist/reminders.d.ts +0 -42
  136. package/dist/reminders.d.ts.map +0 -1
  137. package/dist/reminders.js +0 -207
  138. package/dist/reminders.js.map +0 -1
  139. package/dist/rich-text.d.ts +0 -26
  140. package/dist/rich-text.d.ts.map +0 -1
  141. package/dist/rich-text.js.map +0 -1
  142. package/dist/self-echo.d.ts +0 -19
  143. package/dist/self-echo.d.ts.map +0 -1
  144. package/dist/self-echo.js +0 -54
  145. package/dist/self-echo.js.map +0 -1
  146. package/dist/send.d.ts +0 -24
  147. package/dist/send.d.ts.map +0 -1
  148. package/dist/send.js.map +0 -1
  149. package/dist/settings.d.ts +0 -44
  150. package/dist/settings.d.ts.map +0 -1
  151. package/dist/settings.js +0 -80
  152. package/dist/settings.js.map +0 -1
  153. package/dist/store.d.ts +0 -57
  154. package/dist/store.d.ts.map +0 -1
  155. package/dist/store.js +0 -124
  156. package/dist/store.js.map +0 -1
  157. package/dist/tasks.d.ts +0 -95
  158. package/dist/tasks.d.ts.map +0 -1
  159. package/dist/tasks.js +0 -384
  160. package/dist/tasks.js.map +0 -1
  161. package/dist/types.d.ts +0 -93
  162. package/dist/types.d.ts.map +0 -1
  163. package/dist/types.js +0 -38
  164. package/dist/types.js.map +0 -1
  165. package/dist/watch.d.ts +0 -21
  166. package/dist/watch.d.ts.map +0 -1
  167. package/dist/watch.js +0 -219
  168. package/dist/watch.js.map +0 -1
  169. package/dist/web/data.d.ts +0 -11
  170. package/dist/web/data.d.ts.map +0 -1
  171. package/dist/web/data.js +0 -56
  172. package/dist/web/data.js.map +0 -1
  173. package/dist/web/html.d.ts +0 -5
  174. package/dist/web/html.d.ts.map +0 -1
  175. package/dist/web/html.js +0 -16
  176. package/dist/web/html.js.map +0 -1
  177. package/dist/web/index.d.ts +0 -25
  178. package/dist/web/index.d.ts.map +0 -1
  179. package/dist/web/index.js +0 -335
  180. package/dist/web/index.js.map +0 -1
  181. package/dist/web/render.d.ts +0 -11
  182. package/dist/web/render.d.ts.map +0 -1
  183. package/dist/web/render.js +0 -56
  184. package/dist/web/render.js.map +0 -1
  185. package/dist/web/templates/logs.eta +0 -75
  186. package/dist/web/templates/memory.eta +0 -91
  187. package/dist/web/templates/page.eta +0 -98
@@ -0,0 +1,139 @@
1
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { AssistantEntry, } from "@earendil-works/pi-durable";
3
+ import { finalReplyText } from "../extensions/final-text.js";
4
+ import { Chats } from "./chats.js";
5
+ import { DirectSends } from "./direct-send.js";
6
+ import { Deliveries } from "./replies.js";
7
+ import { RUN_RECORD, Runs, runRecord } from "./run.js";
8
+ async function targets(harness) {
9
+ const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
10
+ const runs = (await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items ?? [];
11
+ return [
12
+ ...chats,
13
+ ...runs.map((run) => ({
14
+ conversationId: run.conversationId,
15
+ chatGuid: run.chatGuid,
16
+ chat: run.chat,
17
+ label: "/run reply",
18
+ })),
19
+ ];
20
+ }
21
+ export async function recoverSending(harness) {
22
+ const directUnknown = await harness.commit(async (tx) => {
23
+ const sends = await tx.doc(DirectSends);
24
+ const changed = [];
25
+ for (const receipt of sends.requests) {
26
+ for (const part of ["textStatus", "fileStatus"]) {
27
+ if (receipt[part] !== "sending")
28
+ continue;
29
+ receipt[part] = "unknown";
30
+ changed.push({ chatGuid: receipt.chatGuid, requestId: receipt.requestId, part });
31
+ }
32
+ }
33
+ return changed;
34
+ }, BACKGROUND_CONTEXT);
35
+ for (const { chatGuid, requestId, part } of directUnknown)
36
+ console.warn("Direct delivery unknown", chatGuid, requestId, part);
37
+ for (const { conversationId } of await targets(harness)) {
38
+ const unknown = await harness.commit(async (tx) => {
39
+ const deliveries = await tx.doc(Deliveries, conversationId);
40
+ const changed = [];
41
+ for (const [answerId, status] of Object.entries(deliveries.answers)) {
42
+ if (status === "sending") {
43
+ deliveries.answers[answerId] = "unknown";
44
+ changed.push(answerId);
45
+ }
46
+ }
47
+ return changed;
48
+ }, BACKGROUND_CONTEXT);
49
+ for (const answerId of unknown)
50
+ console.warn("Reply delivery unknown", conversationId, answerId);
51
+ }
52
+ }
53
+ /** Returns false when the answer must wait for an earlier in-flight send. */
54
+ async function deliverAnswer(harness, storage, target, answerId, send) {
55
+ const { conversationId } = target;
56
+ const claim = await harness.commit(async (tx) => {
57
+ const entry = await tx.entry(AssistantEntry, answerId);
58
+ const text = finalReplyText(entry?.model?.[0]);
59
+ const deliveries = await tx.doc(Deliveries, conversationId);
60
+ const key = String(answerId);
61
+ const decided = () => {
62
+ if (deliveries.scanned === undefined || deliveries.scanned < answerId)
63
+ deliveries.scanned = answerId;
64
+ return true;
65
+ };
66
+ if (!text || deliveries.answers[key] || deliveries.drafts?.[key])
67
+ return { decided: decided() };
68
+ // The owning Session serializes admission with this claim. Read committed native submissions,
69
+ // not arbitrary user entries: passive logs and /run continuation text are not new human input.
70
+ let intervening = false;
71
+ let cursor;
72
+ do {
73
+ const page = await storage.scanSubmissions({ conversationId }, 100, cursor, BACKGROUND_CONTEXT);
74
+ intervening = page.items.some((input) => input.type === "input" &&
75
+ (input.status === "queued" || (input.entry !== undefined && input.entry > answerId)));
76
+ cursor = page.next;
77
+ } while (cursor && !intervening);
78
+ if (intervening) {
79
+ deliveries.drafts ??= {};
80
+ deliveries.drafts[key] = { status: "held", reason: "newer_input" };
81
+ return { decided: decided() };
82
+ }
83
+ // Do not overtake an earlier in-flight send in this chat.
84
+ if (Object.values(deliveries.answers).includes("sending"))
85
+ return { decided: false };
86
+ // Finals are claimed in order, so the only earlier unsent ones are held drafts; this answer replaces them.
87
+ const drafts = deliveries.drafts ?? {};
88
+ for (const [previous, draft] of Object.entries(drafts))
89
+ if (draft.status === "held" && Number(previous) < answerId)
90
+ drafts[previous] = { status: "superseded", replacement: answerId };
91
+ deliveries.answers[key] = "sending";
92
+ return { decided: decided(), text };
93
+ }, BACKGROUND_CONTEXT);
94
+ if (!claim.text)
95
+ return claim.decided;
96
+ let status = "sent";
97
+ try {
98
+ await send(target.chatGuid, claim.text);
99
+ }
100
+ catch {
101
+ // A throwing transport may already have sent. Never retry it automatically.
102
+ status = "unknown";
103
+ }
104
+ await harness.commit(async (tx) => {
105
+ (await tx.doc(Deliveries, conversationId)).answers[String(answerId)] = status;
106
+ }, BACKGROUND_CONTEXT);
107
+ if (status === "unknown")
108
+ console.warn("Reply delivery unknown", conversationId, String(answerId));
109
+ if (target.chat !== undefined) {
110
+ // A queued write never splits a turn that is running in the chat conversation.
111
+ const chat = await harness.conversation(target.chat, BACKGROUND_CONTEXT);
112
+ await chat?.submit({
113
+ type: "write",
114
+ requestId: `${RUN_RECORD}:${answerId}`,
115
+ entry: runRecord(`[${target.label}, ${status}]\n${claim.text}`, Date.now()),
116
+ }, BACKGROUND_CONTEXT);
117
+ }
118
+ return true;
119
+ }
120
+ /** Sends committed final answers in order, including those a /run writes between continuations. */
121
+ export async function deliverReplies(harness, storage, send, enabled) {
122
+ for (const target of await targets(harness)) {
123
+ if (!enabled(target.chatGuid))
124
+ continue;
125
+ const scanned = (await harness.snapshot(Deliveries, target.conversationId, BACKGROUND_CONTEXT))?.scanned;
126
+ const answers = [];
127
+ let cursor;
128
+ do {
129
+ const page = await storage.scanEntries({ conversationId: target.conversationId, ...(scanned === undefined ? {} : { minEntryId: scanned }) }, 100, cursor, BACKGROUND_CONTEXT);
130
+ for (const entry of page.items)
131
+ if (AssistantEntry.is(entry) && finalReplyText(entry.model?.[0]))
132
+ answers.push(entry.id);
133
+ cursor = page.next;
134
+ } while (cursor);
135
+ for (const answerId of answers.reverse())
136
+ if (!(await deliverAnswer(harness, storage, target, answerId, send)))
137
+ break;
138
+ }
139
+ }
@@ -0,0 +1,68 @@
1
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { defineDoc } from "@earendil-works/pi-durable";
3
+ /** Receipts only: no background worker or automatic retry of explicit sends. */
4
+ export const DirectSends = defineDoc({
5
+ kind: "imessage.direct-sends",
6
+ version: 1,
7
+ scope: "session",
8
+ initial: () => ({ requests: [] }),
9
+ });
10
+ export async function deliverDirect(harness, input, sender) {
11
+ const { chatGuid, requestId } = input;
12
+ const text = input.text || null;
13
+ const filePath = input.filePath || null;
14
+ if (!chatGuid.trim() || !requestId.trim() || (!text && !filePath))
15
+ throw new Error("Chat GUID, request ID and text or file path are required");
16
+ const accepted = await harness.commit(async (tx) => {
17
+ const sends = await tx.doc(DirectSends);
18
+ const existing = sends.requests.find((send) => send.chatGuid === chatGuid && send.requestId === requestId);
19
+ if (existing) {
20
+ if (existing.text !== text || existing.filePath !== filePath)
21
+ throw new Error("Send request ID was already used with different content");
22
+ return { claimed: false, receipt: { ...existing } };
23
+ }
24
+ const receipt = {
25
+ chatGuid,
26
+ requestId,
27
+ text,
28
+ filePath,
29
+ textStatus: text ? "not_attempted" : null,
30
+ fileStatus: filePath ? "not_attempted" : null,
31
+ };
32
+ sends.requests.push(receipt);
33
+ return { claimed: true, receipt: { ...receipt } };
34
+ }, BACKGROUND_CONTEXT);
35
+ if (!accepted.claimed)
36
+ return accepted.receipt;
37
+ let receipt = accepted.receipt;
38
+ async function setStatus(part, status) {
39
+ receipt = await harness.commit(async (tx) => {
40
+ const sends = await tx.doc(DirectSends);
41
+ const record = sends.requests.find((send) => send.chatGuid === chatGuid && send.requestId === requestId);
42
+ if (!record)
43
+ throw new Error("Direct send receipt is missing");
44
+ record[part] = status;
45
+ return { ...record };
46
+ }, BACKGROUND_CONTEXT);
47
+ }
48
+ async function attempt(part, send) {
49
+ await setStatus(part, "sending");
50
+ let status = "sent";
51
+ try {
52
+ await send();
53
+ }
54
+ catch {
55
+ status = "unknown";
56
+ }
57
+ await setStatus(part, status);
58
+ if (status === "unknown")
59
+ console.warn("Direct delivery unknown", chatGuid, requestId, part);
60
+ return status;
61
+ }
62
+ // Explicit sends are plain text, independent of automatic reply settings.
63
+ if (text && (await attempt("textStatus", () => sender.sendMessage(chatGuid, text))) !== "sent")
64
+ return receipt;
65
+ if (filePath)
66
+ await attempt("fileStatus", () => sender.sendAttachment(chatGuid, filePath));
67
+ return receipt;
68
+ }
@@ -0,0 +1,79 @@
1
+ import { mkdir, open, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
4
+ import { createRegistry, Harness } from "@earendil-works/pi-durable";
5
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
6
+ import { openNodeJsonlStorage } from "@earendil-works/pi-durable/storage/jsonl/node";
7
+ import { chatBehavior } from "./behavior.js";
8
+ import { RunExtension } from "./run.js";
9
+ export async function openHarness(workingDir, models, extensions, policy) {
10
+ const directory = join(workingDir, "durable");
11
+ await mkdir(directory, { recursive: true });
12
+ const lockPath = join(directory, "owner.lock");
13
+ // Never steal a lock: after an unclean exit the operator must confirm its owner is dead.
14
+ const lock = await open(lockPath, "wx");
15
+ try {
16
+ await lock.writeFile(`${process.pid}\n`);
17
+ const storage = await openNodeJsonlStorage(directory, BACKGROUND_CONTEXT, { fsync: true });
18
+ try {
19
+ const registry = createRegistry();
20
+ registry.install(chatBehavior(storage));
21
+ registry.install(RunExtension);
22
+ for (const extension of extensions) {
23
+ if (registry.snapshot().extension(extension.name))
24
+ throw new Error(`Duplicate extension name: ${extension.name}`);
25
+ registry.install(extension);
26
+ }
27
+ const env = new NodeExecutionEnv({ cwd: workingDir });
28
+ const harness = await Harness.open(storage,
29
+ // Large Codex contexts exceed WebSocket frame limits; force SSE.
30
+ {
31
+ models,
32
+ registry,
33
+ env: () => env,
34
+ // Consume all admitted corrections at the next native boundary, not one per turn.
35
+ settings: {
36
+ get stream() {
37
+ return { ...policy.stream, transport: "sse" };
38
+ },
39
+ get retry() {
40
+ return policy.retry;
41
+ },
42
+ steeringMode: "all",
43
+ // Only run conversations select the run extension; reread so reinstalls apply.
44
+ get extensions() {
45
+ return registry
46
+ .snapshot()
47
+ .installed()
48
+ .filter((extension) => extension.name !== RunExtension.name);
49
+ },
50
+ },
51
+ }, BACKGROUND_CONTEXT);
52
+ let closing;
53
+ return {
54
+ harness,
55
+ storage,
56
+ registry,
57
+ close(beforeUnlock) {
58
+ closing ??= (async () => {
59
+ await harness.close(BACKGROUND_CONTEXT);
60
+ // External browser resources must be closed before another owner can resume this workspace.
61
+ await beforeUnlock?.();
62
+ await lock.close();
63
+ await unlink(lockPath);
64
+ })();
65
+ return closing;
66
+ },
67
+ };
68
+ }
69
+ catch (error) {
70
+ await storage.close(BACKGROUND_CONTEXT);
71
+ throw error;
72
+ }
73
+ }
74
+ catch (error) {
75
+ await lock.close();
76
+ await unlink(lockPath);
77
+ throw error;
78
+ }
79
+ }
@@ -0,0 +1,32 @@
1
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { configure, defineDoc } from "@earendil-works/pi-durable";
3
+ /** Retained metadata for read-only inspection of historical isolated conversations. */
4
+ export const Sessions = defineDoc({
5
+ kind: "imessage.sessions",
6
+ version: 1,
7
+ scope: "session",
8
+ initial: () => ({ items: [] }),
9
+ });
10
+ /** Explicit health checks reuse one tool-less conversation and never deliver to a chat. */
11
+ export async function submitHealth(harness, defaults) {
12
+ const key = JSON.stringify(["health", "model-health"]);
13
+ const id = await harness.commit(async (tx) => {
14
+ const sessions = await tx.doc(Sessions);
15
+ const found = sessions.items.find((item) => item.key === key);
16
+ const id = found?.conversationId ?? (await tx.createConversation({ ownership: { kind: "ownerless" } })).id;
17
+ await configure(tx, id, { ...defaults, tools: [], instructions: "Reply OK. Do not use tools." });
18
+ if (!found)
19
+ sessions.items.push({
20
+ key,
21
+ label: "model-health",
22
+ conversationId: id,
23
+ chatGuid: "health",
24
+ deliver: false,
25
+ });
26
+ return id;
27
+ }, BACKGROUND_CONTEXT);
28
+ const conversation = await harness.conversation(id, BACKGROUND_CONTEXT);
29
+ if (!conversation)
30
+ throw new Error("Model health conversation missing");
31
+ return conversation.submit({ type: "input", content: "Reply OK.", requestId: crypto.randomUUID(), whenBusy: "followUp" }, BACKGROUND_CONTEXT);
32
+ }
@@ -0,0 +1,69 @@
1
+ import { join } from "node:path";
2
+ import { ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
3
+ export function openModels(agentDir) {
4
+ return ModelRuntime.create({
5
+ authPath: join(agentDir, "auth.json"),
6
+ modelsPath: join(agentDir, "models.json"),
7
+ });
8
+ }
9
+ /** Read defaults and use only an explicitly configured, available fallback. */
10
+ export async function readDefaults(runtime, workingDir, agentDir, policy = {}) {
11
+ const settings = SettingsManager.create(workingDir, agentDir);
12
+ const provider = settings.getDefaultProvider();
13
+ const modelId = settings.getDefaultModel();
14
+ const thinkingLevel = settings.getDefaultThinkingLevel();
15
+ await runtime.refresh();
16
+ if (provider && modelId && runtime.getModel(provider, modelId) && runtime.hasConfiguredAuth(provider))
17
+ return { model: { provider, modelId }, thinkingLevel };
18
+ const fallback = policy.fallback;
19
+ if (!fallback ||
20
+ !runtime.getModel(fallback.provider, fallback.modelId) ||
21
+ !runtime.hasConfiguredAuth(fallback.provider))
22
+ throw new Error(`Default model ${provider}/${modelId} is unavailable; no available configured fallback`);
23
+ console.warn(`Default model ${provider}/${modelId} unavailable; using ${fallback.provider}/${fallback.modelId}`);
24
+ return { model: { ...fallback }, thinkingLevel };
25
+ }
26
+ /** Match Pi's experimental Durable request policy; execution and retries remain native. */
27
+ export function requestSettings(settings) {
28
+ return {
29
+ get stream() {
30
+ const provider = settings.getProviderRetrySettings();
31
+ const idle = settings.getHttpIdleTimeoutMs();
32
+ return {
33
+ timeoutMs: provider.timeoutMs ?? (idle === 0 ? 2_147_483_647 : idle),
34
+ maxRetryDelayMs: provider.maxRetryDelayMs,
35
+ ...(provider.maxRetries === undefined ? {} : { maxRetries: provider.maxRetries }),
36
+ };
37
+ },
38
+ get retry() {
39
+ return settings.getRetrySettings();
40
+ },
41
+ };
42
+ }
43
+ /** A live configuration getter applies tier changes to subsequent requests only. */
44
+ export function withModelPolicy(models, policy) {
45
+ return new Proxy(models, {
46
+ get(target, key) {
47
+ if (key === "streamSimple") {
48
+ const streamSimple = (model, context, options) => {
49
+ const tier = policy().codexServiceTier;
50
+ if (!tier || model.provider !== "openai-codex" || !/^gpt-/.test(model.id))
51
+ return target.streamSimple(model, context, options);
52
+ const configured = {
53
+ ...options,
54
+ async onPayload(payload, payloadModel) {
55
+ const current = (await options?.onPayload?.(payload, payloadModel)) ?? payload;
56
+ if (typeof current !== "object" || current === null || Array.isArray(current))
57
+ return current;
58
+ return { ...current, service_tier: tier };
59
+ },
60
+ };
61
+ return target.streamSimple(model, context, configured);
62
+ };
63
+ return streamSimple;
64
+ }
65
+ const value = Reflect.get(target, key, target);
66
+ return typeof value === "function" ? value.bind(target) : value;
67
+ },
68
+ });
69
+ }
@@ -0,0 +1,87 @@
1
+ /** The plain-text adapter; apps can render the same fields without parsing this text. */
2
+ export function formatProgress(event) {
3
+ const prefix = event.parents.length ? `${event.parents.join("/")}/` : "";
4
+ const header = `[${prefix}${event.type}]`;
5
+ if (event.phase === "response") {
6
+ return `${event.success ? "✓" : "×"} ${header} ${event.name} (${(event.elapsedMs / 1000).toFixed(1)}s)`;
7
+ }
8
+ const summary = event.type === "tool" ? (event.name === "bash" ? event.commandPreview : event.summary) : undefined;
9
+ return `→ ${header} ${event.name}${summary ? `: ${summary}` : ""}`;
10
+ }
11
+ /** Screen the whole command before showing a bounded, non-clickable preview. */
12
+ export function commandPreview(command) {
13
+ if (typeof command !== "string")
14
+ return "[command hidden]";
15
+ // Match a complete shell word, including concatenated quotes and escaped spaces.
16
+ const word = String.raw `(?:"(?:\\[\s\S]|[^"\\])*(?:"|$)|'[^']*(?:'|$)|\$\([\s\S]*\)|\x60(?:\\[\s\S]|[^\x60\\])*\x60|\\[\s\S]|[^\s;|&<>"'\\\x60])+`;
17
+ const key = String.raw `[\w.-]*(?:secret|token|password|passwd|credential|authorization|api[._-]?key|private[._-]?key|bearer)[\w.-]*`;
18
+ let maskNext = false;
19
+ const screened = command
20
+ .replace(/\\\r?\n/g, "")
21
+ .replace(/-----BEGIN[^\r\n]*PRIVATE KEY-----[\s\S]*?(?:-----END[^\r\n]*PRIVATE KEY-----|$)/g, "***")
22
+ .replace(/\b(?:sk-[a-z0-9_-]+|gh[pousr]_[a-z0-9_]+|github_pat_[a-z0-9_]+)\b/gi, "***")
23
+ .replace(/(https?:\/\/)[^\s/<>]+@/gi, "$1***@")
24
+ .replace(new RegExp(word, "g"), (argument) => {
25
+ if (maskNext) {
26
+ maskNext = false;
27
+ return "***";
28
+ }
29
+ const option = argument.replace(/["']/g, "");
30
+ if (new RegExp(`^(?:--${key}|--user|-u|bearer)$`, "i").test(option)) {
31
+ maskNext = true;
32
+ return argument;
33
+ }
34
+ // Headers can concatenate quoted/unquoted fragments in one shell word.
35
+ if (/\b(?:proxy-)?authorization\s*:/i.test(argument)) {
36
+ const quote = /^["']/.test(argument) && argument.at(-1) === argument[0] ? argument[0] : "";
37
+ return argument.replace(/(\b(?:proxy-)?authorization\s*:\s*)[\s\S]*/i, "$1***") + quote;
38
+ }
39
+ // Escaped JSON is an opaque shell argument; don't expose partial values.
40
+ if (new RegExp(String.raw `\\["']${key}\\["']\s*:`, "i").test(argument))
41
+ return "***";
42
+ return argument
43
+ .replace(new RegExp(String.raw `(["']${key}["']\s*:\s*)("(?:\\[\s\S]|[^"\\])*"|'[^']*'|[^\s,}\]]+)`, "gi"), (_match, prefix, value) => `${prefix}${/^["']/.test(value) ? `${value[0]}***${value[0]}` : "***"}`)
44
+ .replace(new RegExp(String.raw `((?:\b${key}|--user)\s*[=:]\s*)[\s\S]*`, "gi"), "$1***")
45
+ .replace(/(\bbearer\s+)[\s\S]*/gi, "$1***");
46
+ })
47
+ .replace(/\s+/g, " ")
48
+ .trim()
49
+ .replace(/https?:\/\/[^\s"'`<>]+/gi, (url) => url.replaceAll(":", ":").replaceAll("/", "/").replaceAll(".", "."))
50
+ .replace(/\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|org|net|cn|io|dev|app|co|edu|gov|uk|us)\b/gi, (host) => host.replaceAll(".", "."));
51
+ const characters = Array.from(screened);
52
+ return characters.length > 120 ? `${characters.slice(0, 119).join("")}…` : screened;
53
+ }
54
+ /** Argument previews for non-bash tools; bash requests show only `commandPreview`. */
55
+ export function argumentSummary(arguments_) {
56
+ return Object.entries(arguments_)
57
+ .filter(([key]) => key !== "timeout")
58
+ .map(([key, value]) => {
59
+ if (/secret|token|password|credential|authorization|api.?key/i.test(key))
60
+ return `${key}=[redacted]`;
61
+ if (typeof value === "number" || typeof value === "boolean")
62
+ return `${key}=${value}`;
63
+ if (typeof value === "string") {
64
+ if (["path", "filePath", "cwd", "query"].includes(key)) {
65
+ if (/secret|token|password|credential|authorization|api.?key|sk-[a-z0-9]|gh[pousr]_|github_pat_|bearer\s|https?:\/\/\S+@/i.test(value))
66
+ return `${key}=[redacted]`;
67
+ return `${key}=${value
68
+ .replace(/https?:\/\/\S+/gi, "[link omitted]")
69
+ .replace(/[\r\n\t]/g, " ")
70
+ .slice(0, 160)}`;
71
+ }
72
+ if (key === "url") {
73
+ try {
74
+ const url = new URL(value);
75
+ return `${key}=${url.hostname.replaceAll(".", ".")}`;
76
+ }
77
+ catch {
78
+ return `${key}=[${value.length} chars]`;
79
+ }
80
+ }
81
+ return `${key}=[${value.length} chars]`;
82
+ }
83
+ return `${key}=[${Array.isArray(value) ? `${value.length} items` : "object"}]`;
84
+ })
85
+ .join(", ")
86
+ .slice(0, 400);
87
+ }
@@ -0,0 +1,135 @@
1
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { isReplyEnabled, readSettings } from "../config/settings.js";
3
+ import { Chats } from "./chats.js";
4
+ import { takeScheduledCompaction } from "./compaction.js";
5
+ import { deliverDirect } from "./direct-send.js";
6
+ import { argumentSummary, commandPreview, formatProgress } from "./progress-format.js";
7
+ import { Runs } from "./run.js";
8
+ async function calledTool(harness, task) {
9
+ if (task.kind !== "pi.tool")
10
+ return;
11
+ // The registered native task owns this input shape and its branded assistant ID.
12
+ const input = task.input;
13
+ const entry = await harness.commit((tx) => tx.entry(input.assistant), BACKGROUND_CONTEXT);
14
+ const message = entry?.model?.[0];
15
+ if (message?.role !== "assistant")
16
+ return;
17
+ const call = message.content.find((part) => part.type === "toolCall" && part.id === input.callId);
18
+ return call?.type === "toolCall" ? call : undefined;
19
+ }
20
+ /** Observe native admission and settlement; Durable owns execution and recovery. */
21
+ export async function startProgress(harness, workingDir, sender) {
22
+ // Existing live work belongs to the previous startup; do not replay historical progress.
23
+ const seen = new Set((await harness.inspect(BACKGROUND_CONTEXT)).tasks.map(({ record }) => record.id));
24
+ const active = new Map();
25
+ let pending = Promise.resolve();
26
+ async function notify(task, notice) {
27
+ if (task.kind === "pi.compaction" && (await takeScheduledCompaction(harness, task.id)))
28
+ return;
29
+ const settings = await readSettings(workingDir);
30
+ if (!settings.progressMessages)
31
+ return;
32
+ const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
33
+ const runs = (await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items ?? [];
34
+ let conversationId = task.conversationId;
35
+ let chatGuid;
36
+ const parents = [];
37
+ while (!chatGuid) {
38
+ chatGuid = [...chats, ...runs].find((item) => item.conversationId === conversationId)?.chatGuid;
39
+ if (chatGuid)
40
+ break;
41
+ const conversation = await harness.commit((tx) => tx.conversation(conversationId), BACKGROUND_CONTEXT);
42
+ if (!conversation?.owner)
43
+ return; // Health and unrelated ownerless conversations stay local.
44
+ const owner = await harness.getTask(conversation.owner.taskId, BACKGROUND_CONTEXT);
45
+ const call = owner && (await calledTool(harness, owner));
46
+ parents.unshift(call?.name ?? owner?.kind ?? "child");
47
+ conversationId = conversation.owner.conversationId;
48
+ }
49
+ if (!isReplyEnabled(settings, chatGuid))
50
+ return;
51
+ const tool = await calledTool(harness, task);
52
+ const request = tool
53
+ ? {
54
+ phase: "request",
55
+ type: "tool",
56
+ parents,
57
+ name: tool.name,
58
+ ...(tool.name === "bash"
59
+ ? { commandPreview: commandPreview(tool.arguments.command) }
60
+ : { summary: argumentSummary(tool.arguments) }),
61
+ }
62
+ : { phase: "request", type: "task", parents, name: task.kind };
63
+ notice.chatGuid = chatGuid;
64
+ notice.request = request;
65
+ const text = formatProgress(request);
66
+ const receipt = await deliverDirect(harness, { chatGuid, requestId: `progress:${task.id}`, text }, sender);
67
+ if (receipt.textStatus === "unknown")
68
+ console.warn("Progress delivery unknown", task.id, chatGuid);
69
+ }
70
+ async function settled(task, notice, elapsed) {
71
+ if (!notice.chatGuid || !notice.request || task.state.status !== "terminal")
72
+ return;
73
+ const settings = await readSettings(workingDir);
74
+ if (!settings.progressMessages || !isReplyEnabled(settings, notice.chatGuid))
75
+ return;
76
+ const outcome = task.state.outcome;
77
+ let success = outcome.status === "completed";
78
+ if (success && task.kind === "pi.tool" && outcome.status === "completed") {
79
+ const result = outcome.result;
80
+ const entryId = result && typeof result === "object" && "entryId" in result ? result.entryId : undefined;
81
+ const entry = typeof entryId === "number"
82
+ ? await harness.commit((tx) => tx.entry(entryId), BACKGROUND_CONTEXT)
83
+ : undefined;
84
+ const message = entry?.model?.[0];
85
+ success = message?.role === "toolResult" && !message.isError;
86
+ }
87
+ const text = formatProgress({ ...notice.request, phase: "response", success, elapsedMs: elapsed });
88
+ const receipt = await deliverDirect(harness, {
89
+ chatGuid: notice.chatGuid,
90
+ requestId: `progress:${task.id}:done`,
91
+ text,
92
+ }, sender);
93
+ if (receipt.textStatus === "unknown")
94
+ console.warn("Progress completion delivery unknown", task.id, notice.chatGuid);
95
+ }
96
+ const unsubscribe = harness.subscribeCommits(({ changes }) => {
97
+ for (const change of changes) {
98
+ if (change.type !== "task" || change.value.kind === "pi.generation")
99
+ continue;
100
+ const task = change.value;
101
+ if (task.state.status === "terminal") {
102
+ seen.delete(task.id);
103
+ const notice = active.get(task.id);
104
+ active.delete(task.id);
105
+ if (notice) {
106
+ const elapsed = performance.now() - notice.admittedAt;
107
+ pending = pending
108
+ .then(() => settled(task, notice, elapsed))
109
+ .catch((error) => {
110
+ console.warn("Progress completion notification failed", task.id, error);
111
+ });
112
+ }
113
+ continue;
114
+ }
115
+ if (task.state.status !== "pending" || seen.has(task.id))
116
+ continue;
117
+ seen.add(task.id);
118
+ const notice = { admittedAt: performance.now() };
119
+ active.set(task.id, notice);
120
+ // The synchronous observer must not call Session APIs or block adoption.
121
+ pending = pending
122
+ .then(() => notify(task, notice))
123
+ .catch((error) => {
124
+ console.warn("Progress notification failed", task.id, error);
125
+ });
126
+ }
127
+ });
128
+ return {
129
+ flush: () => pending,
130
+ async close() {
131
+ unsubscribe();
132
+ await pending;
133
+ },
134
+ };
135
+ }