@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.
package/README.md CHANGED
@@ -32,18 +32,8 @@ For installation and service operations, see [ops/README.md](ops/README.md).
32
32
 
33
33
  ## Commands
34
34
 
35
- Send these commands as iMessages:
36
-
37
- | Command | Description |
38
- |---|---|
39
- | `/help` | List commands |
40
- | `/new` | Cancel current work and start a fresh conversation |
41
- | `/status` | Show tokens, context usage, and model |
42
- | `/thinking <level\|default>` | Set thinking for this chat or follow the default |
43
- | `/compact` | Compress context without replaying prior work |
44
- | `/run <duration> [task]` | Keep working for up to the given time, e.g. `/run 1h`; `/stop` ends it early |
45
- | `/stop` | Abort current work and stop `/run`; later messages are still answered |
46
- | `/reload` | Refresh models/auth, AGENTS, skills and extensions without cancelling work |
35
+ Send `/help` in a chat to list commands: `/new`, `/status`, `/thinking`,
36
+ `/verbose`, `/compact`, `/run`, `/stop` and `/reload`.
47
37
 
48
38
  ## Configuration
49
39
 
@@ -64,27 +54,6 @@ Edit `WORKING_DIR/settings.json`. All fields are optional.
64
54
  }
65
55
  ```
66
56
 
67
- Add chat GUIDs to `whitelist` to enable replies; `blacklist: ["*"]` blocks replies
68
- elsewhere. An explicit blacklist entry takes priority. Messages are always logged.
69
- Rich text is optional; `markdown: true` renders bold spans in direct iMessage chats.
70
-
71
- ### Workspace extensions and scheduler
72
-
73
- Context compaction runs every six hours as a native Durable background task.
74
- Personal tools, tasks and optional daily/interval schedules live in
75
- `WORKING_DIR/extensions/<name>/index.ts` with `config.json`, not framework
76
- settings. Startup and `/reload` load them; the assistant can use
77
- `reload_extensions` to apply its own changes.
78
-
79
- See [workspace extensions](docs/workspace-extensions.md) for the factory contract
80
- and scheduling rules.
81
- One-off runs use the same scheduler: declare a stable `runRequests` ID, target
82
- schedule and `at: "now"` (or epoch milliseconds) in the extension configuration,
83
- then call `reload_extensions`. No restart or separate execution API is needed.
84
-
85
- Times use the service machine's local timezone. Saved deadlines survive reload
86
- and restart; view schedules and latest-ten execution history at `/scheduled`.
87
-
88
57
  ### Model policy
89
58
 
90
59
  Optional `settings.json` fields, applied at startup and `/reload`:
@@ -98,11 +67,8 @@ Optional `settings.json` fields, applied at startup and `/reload`:
98
67
  }
99
68
  ```
100
69
 
101
- Without a fallback, an unavailable default fails explicitly. Without a tier,
102
- provider requests are unchanged. `codexServiceTier` accepts `default` or
103
- `priority`; priority may have a different cost. Existing deployments migrate
104
- their previous choices into explicit configuration rather than silently changing
105
- behavior.
70
+ Without a fallback, an unavailable default fails explicitly. `codexServiceTier`
71
+ accepts `default` or `priority`; priority may have a different cost.
106
72
 
107
73
  ### Environment Variables
108
74
 
@@ -118,9 +84,7 @@ All variables are optional.
118
84
  ### Web and API
119
85
 
120
86
  See the [HTTP API](docs/api.md) for read-only state and model health checks.
121
- The agent sends files and extra messages with `send_message`. The scheduler
122
- provides daily/interval cadence and one-off triggers for workspace tasks, plus built-in six-hour compaction.
123
- It does not revive legacy cron files or HTTP prompt/send endpoints.
87
+ The agent sends files and extra messages with `send_message`.
124
88
 
125
89
  ## How It Works
126
90
 
@@ -129,12 +93,40 @@ Messages chat.db → Watcher → Pi Durable → Messages.app
129
93
  ```
130
94
 
131
95
  Each chat has its own conversation, persisted under `WORKING_DIR/durable/`.
132
- Durable resumes pending work after a crash. Agent replies are sent at most once;
133
- uncertain send results are flagged, never automatically resent.
96
+ Durable resumes pending work after a crash. Replies are sent at most once;
97
+ uncertain sends are flagged, never resent. Context is compacted silently every
98
+ six hours and near the model's limit.
134
99
 
135
- Context is compacted silently every 6 hours and near the model's limit. Failed
136
- compaction keeps the old context. `/new` starts fresh without deleting searchable
137
- history.
100
+ ## Layout
101
+
102
+ This repository is the framework; `WORKING_DIR` is one deployment's data and code.
103
+ Startup, `/reload` and the assistant's `reload_extensions` load the extensions.
104
+ See [workspace extensions](docs/workspace-extensions.md),
105
+ [ADR 0036](docs/adr/0036-workspace-policy-and-neutral-service-id.md) and
106
+ [ADR 0042](docs/adr/0042-background-schedules-and-workspace-extensions.md).
107
+
108
+ ```text
109
+ pi-imessage/ framework, shared by every deployment
110
+ ├── src/
111
+ │ ├── main.ts, cli.ts startup, wiring, launchd install
112
+ │ ├── web/ read-only HTTP API and pages
113
+ │ ├── agent/ Durable harness, models, prompt, delivery, scheduler
114
+ │ ├── extensions/ built-in tools, workspace extension loader
115
+ │ ├── transport/ chat.db polling, Messages.app sending
116
+ │ └── config/ settings, environment, chat allowlist
117
+ ├── docs/ architecture and ADRs (the design ground truth)
118
+ ├── examples/workspace-extensions/ reference extensions to copy into a workspace
119
+ ├── ops/ deployment, preflight and smoke scripts
120
+ └── test/
121
+
122
+ WORKING_DIR/ (default ~/.pi/imessage) one deployment, back it up yourself
123
+ ├── settings.json chat allowlist, model policy
124
+ ├── extensions/<name>/ personal tools, tasks and schedules
125
+ ├── skills/file-memory/ structured memory store and core summary
126
+ ├── durable/ conversation state; never edit by hand
127
+ ├── attachments/ archived incoming files
128
+ └── service.log
129
+ ```
138
130
 
139
131
  ## Development
140
132
 
@@ -6,13 +6,34 @@ export const Chats = defineDoc({
6
6
  version: 1,
7
7
  scope: "session",
8
8
  initial: () => ({ items: [] }),
9
+ checkpointWhen: () => false,
9
10
  });
10
11
  export const WatchCursor = defineDoc({
11
12
  kind: "imessage.watch-cursor",
12
13
  version: 1,
13
14
  scope: "session",
14
15
  initial: () => ({}),
16
+ checkpointWhen: () => false,
15
17
  });
18
+ export function progressEnabled(settings, chats, chatGuid) {
19
+ return chats.find((chat) => chat.chatGuid === chatGuid)?.progress ?? settings.progressMessages;
20
+ }
21
+ /** Stores the chat's /verbose choice; `undefined` returns the chat to the global setting. */
22
+ export async function setChatProgress(harness, chatGuid, progress) {
23
+ await harness.commit(async (tx) => {
24
+ const chats = await tx.doc(Chats);
25
+ const index = chats.items.findIndex((item) => item.chatGuid === chatGuid);
26
+ if (index < 0)
27
+ throw new Error("Chat conversation is missing");
28
+ const { conversationId } = chats.items[index];
29
+ // Replace the record so the stored delta never carries an undefined value.
30
+ chats.items.splice(index, 1, {
31
+ chatGuid,
32
+ conversationId,
33
+ ...(progress === undefined ? {} : { progress }),
34
+ });
35
+ }, BACKGROUND_CONTEXT);
36
+ }
16
37
  export function formatInput(input) {
17
38
  let prefix = "";
18
39
  if (input.sender !== undefined) {
@@ -1,14 +1,18 @@
1
1
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
- import { LiveDoc, UsageDoc, UserEntry, } from "@earendil-works/pi-durable";
3
- import { chatConversation } from "./chats.js";
2
+ import { getCurrentSystemMessage } from "@earendil-works/pi-ai/utils/transcript";
3
+ import { calculateContextTokens, estimateTokens } from "@earendil-works/pi-coding-agent";
4
+ import { LiveDoc, UserEntry, } from "@earendil-works/pi-durable";
5
+ import { readSettings } from "../config/settings.js";
6
+ import { Chats, chatConversation, progressEnabled, setChatProgress } from "./chats.js";
4
7
  import { activeRun, Runs, startRun } from "./run.js";
5
8
  const help = [
6
9
  "/help - list commands",
7
10
  "/new - stop current work and start an empty context",
8
- "/status - show messages, tokens, context usage, model and run",
11
+ "/status - show messages, context usage, model and the active run",
9
12
  "/compact [instructions] - compress context",
10
13
  "/thinking <level|default> - set thinking for this chat",
11
- "/reload - reload models, instructions and skills; apply the default model to this chat",
14
+ "/verbose [on|off|default] - show or set tool progress notices for this chat",
15
+ "/reload - reload models, instructions and skills; apply the default model to this chat and show /status",
12
16
  "/run <duration> [task] - keep working until done, blocked or the deadline, e.g. /run 1h",
13
17
  "/stop - stop the current work or /run",
14
18
  ].join("\n");
@@ -23,7 +27,7 @@ export function parseDuration(value) {
23
27
  return (Number(match[1] ?? 0) * 60 + Number(match[2] ?? 0)) * 60000;
24
28
  }
25
29
  function tokens(count) {
26
- return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
30
+ return count >= 1000 ? `${Math.round(count / 1000)}k` : String(count);
27
31
  }
28
32
  export function compactionReply(outcome, placement) {
29
33
  if (outcome.status === "aborted")
@@ -57,6 +61,41 @@ export async function runCommand(options) {
57
61
  if (run)
58
62
  await (await harness.conversation(run.conversationId, BACKGROUND_CONTEXT))?.configure(change, BACKGROUND_CONTEXT);
59
63
  };
64
+ // The /status report: user messages with context usage, then model with thinking (ADR 0013).
65
+ // A third line appears only while a /run is active. Cumulative token totals are deliberately omitted.
66
+ const status = async (withRun) => {
67
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
68
+ const context = await conversation.context(BACKGROUND_CONTEXT);
69
+ const window = agent.model && options.models.getModel(agent.model.provider, agent.model.modelId)?.contextWindow;
70
+ // Context size: the last reported usage, or before the first reply a local estimate of about four characters
71
+ // per token. After /new the next request restates the system prompt and tools, so the estimate replays them
72
+ // from the context just before the reset marker.
73
+ const last = context.messages.findLast((message) => message.role === "assistant" && message.usage.totalTokens > 0);
74
+ const estimate = async () => {
75
+ let system = getCurrentSystemMessage(context.messages);
76
+ if (!system && context.head) {
77
+ const head = context.head.id;
78
+ const before = (await conversation.entries({ maxEntryId: head }, 2, undefined, BACKGROUND_CONTEXT)).items.find((entry) => entry.id !== head);
79
+ if (before)
80
+ system = getCurrentSystemMessage((await conversation.context(BACKGROUND_CONTEXT, { at: before.id })).messages);
81
+ }
82
+ return context.messages
83
+ .filter((message) => message.role !== "system")
84
+ .reduce((sum, message) => sum + estimateTokens(message), system ? estimateTokens(system) : 0);
85
+ };
86
+ const used = last?.role === "assistant" ? calculateContextTokens(last.usage) : await estimate();
87
+ const usage = `${last ? "" : "~"}${((used / (window || 1)) * 100).toFixed(1)}%`;
88
+ return [
89
+ [
90
+ `${context.messages.filter((message) => message.role === "user").length} msgs`,
91
+ window ? `${usage} / ${tokens(window)}` : "",
92
+ ]
93
+ .filter(Boolean)
94
+ .join(" · "),
95
+ `${agent.model ? `${agent.model.provider}/${agent.model.modelId}` : "unset"} · ${agent.thinkingLevel}`,
96
+ ...(withRun && run ? [`run · until ${new Date(run.deadline).toISOString()}`] : []),
97
+ ].join("\n");
98
+ };
60
99
  switch (name) {
61
100
  case "/help":
62
101
  return { reply: help };
@@ -91,38 +130,13 @@ export async function runCommand(options) {
91
130
  return { reply: "Stopped." };
92
131
  }
93
132
  case "/new":
94
- case "/status": {
95
- if (name === "/new") {
96
- // The run task belongs to the chat conversation, so this abort stops it too.
97
- await conversation.abort(BACKGROUND_CONTEXT);
98
- await options.closeBrowsers?.(conversation.id);
99
- await conversation.reset(undefined, BACKGROUND_CONTEXT);
100
- }
101
- const agent = await conversation.agent(BACKGROUND_CONTEXT);
102
- const context = await conversation.context(BACKGROUND_CONTEXT);
103
- const usage = Object.values((await harness.snapshot(UsageDoc, conversation.id, BACKGROUND_CONTEXT))?.models ?? {});
104
- const last = context.messages.findLast((message) => message.role === "assistant" && message.usage.totalTokens > 0);
105
- const window = agent.model && options.models.getModel(agent.model.provider, agent.model.modelId)?.contextWindow;
106
- const used = last?.role === "assistant" ? last.usage.totalTokens : undefined;
107
- return {
108
- reply: [
109
- [
110
- `${context.messages.filter((message) => message.role === "user").length} msgs -`,
111
- `in ${tokens(usage.reduce((sum, item) => sum + item.input, 0))}`,
112
- `out ${tokens(usage.reduce((sum, item) => sum + item.output, 0))}`,
113
- window
114
- ? `${used === undefined ? "?" : `${((used / window) * 100).toFixed(1)}%`}/${tokens(window)}`
115
- : "",
116
- ]
117
- .join(" ")
118
- .trim(),
119
- `Model: ${agent.model ? `${agent.model.provider}/${agent.model.modelId}` : "unset"}, thinking: ${agent.thinkingLevel}`,
120
- ...(name === "/status"
121
- ? [`Run: ${run ? `until ${new Date(run.deadline).toISOString()}` : "none"}`]
122
- : []),
123
- ].join("\n"),
124
- };
125
- }
133
+ // The run task belongs to the chat conversation, so this abort stops it too.
134
+ await conversation.abort(BACKGROUND_CONTEXT);
135
+ await options.closeBrowsers?.(conversation.id);
136
+ await conversation.reset(undefined, BACKGROUND_CONTEXT);
137
+ return { reply: await status(false) };
138
+ case "/status":
139
+ return { reply: await status(true) };
126
140
  case "/compact":
127
141
  return {
128
142
  wait: await conversation.compact(argument || undefined, BACKGROUND_CONTEXT),
@@ -136,10 +150,24 @@ export async function runCommand(options) {
136
150
  return { reply: `Usage: /thinking <${thinkingLevels.join("|")}|default>` };
137
151
  await configure({ thinkingLevel: argument });
138
152
  return { reply: `Thinking: ${argument} (this chat only)` };
153
+ case "/verbose": {
154
+ if (argument && argument !== "on" && argument !== "off" && argument !== "default")
155
+ return { reply: "Usage: /verbose [on|off|default]" };
156
+ if (argument)
157
+ await setChatProgress(harness, chatGuid, argument === "default" ? undefined : argument === "on");
158
+ const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
159
+ const chat = chats.find((item) => item.chatGuid === chatGuid);
160
+ const settings = await readSettings(options.workingDir);
161
+ const state = progressEnabled(settings, chats, chatGuid) ? "on" : "off";
162
+ if (chat?.progress === undefined)
163
+ return { reply: `Verbose: ${state} (default)` };
164
+ return { reply: `Verbose: ${state} (this chat only)` };
165
+ }
139
166
  case "/reload": {
140
167
  const current = await options.reload();
141
168
  await configure({ model: current.model });
142
- return { reply: `Reloaded. Model: ${current.model.provider}/${current.model.modelId}` };
169
+ // Reply in the /status format so the applied model and run are visible at once.
170
+ return { reply: await status(true) };
143
171
  }
144
172
  case "/run": {
145
173
  const duration = parseDuration(rest[0] ?? "");
@@ -6,6 +6,7 @@ export const DirectSends = defineDoc({
6
6
  version: 1,
7
7
  scope: "session",
8
8
  initial: () => ({ requests: [] }),
9
+ checkpointWhen: () => false,
9
10
  });
10
11
  export async function deliverDirect(harness, input, sender) {
11
12
  const { chatGuid, requestId } = input;
@@ -0,0 +1,37 @@
1
+ const REASON_LIMIT = 200;
2
+ const failedAssistant = (message) => message.role === "assistant" && message.stopReason === "error";
3
+ /**
4
+ * Service-log error lines for failures that break normal operation (ADR 0053). Durable keeps a failed
5
+ * task only in its records, so without this an exhausted provider quota leaves stdout silent while the
6
+ * process, port and HTTP checks look healthy. Each error kind is one branch here; the first is a model
7
+ * call that ends failed, which leaves the chat or scheduled job without the answer it was waiting for.
8
+ */
9
+ export function logErrors(harness) {
10
+ return harness.subscribeCommits(({ changes }) => {
11
+ for (const change of changes) {
12
+ if (change.type !== "task" || change.value.kind !== "pi.generation")
13
+ continue;
14
+ const task = change.value;
15
+ if (task.state.status !== "terminal")
16
+ continue;
17
+ const { outcome } = task.state;
18
+ if (outcome.status === "completed" || outcome.status === "aborted")
19
+ continue;
20
+ // The failing assistant message is committed together with the task and names provider and model.
21
+ const failed = changes
22
+ .flatMap((item) => item.type === "entry" && item.value.conversationId === task.conversationId
23
+ ? (item.value.model ?? [])
24
+ : [])
25
+ .find(failedAssistant);
26
+ console.error(new Date().toISOString(), "Model call failed", {
27
+ kind: task.kind,
28
+ task: task.id,
29
+ conversation: task.conversationId,
30
+ status: outcome.status,
31
+ provider: failed?.provider,
32
+ model: failed?.model,
33
+ reason: (outcome.error?.message ?? outcome.reason ?? "").slice(0, REASON_LIMIT),
34
+ });
35
+ }
36
+ });
37
+ }
@@ -6,6 +6,7 @@ export const Sessions = defineDoc({
6
6
  version: 1,
7
7
  scope: "session",
8
8
  initial: () => ({ items: [] }),
9
+ checkpointWhen: () => false,
9
10
  });
10
11
  /** Explicit health checks reuse one tool-less conversation and never deliver to a chat. */
11
12
  export async function submitHealth(harness, defaults) {
@@ -1,6 +1,10 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
1
5
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
6
  import { isReplyEnabled, readSettings } from "../config/settings.js";
3
- import { Chats } from "./chats.js";
7
+ import { Chats, progressEnabled } from "./chats.js";
4
8
  import { takeScheduledCompaction } from "./compaction.js";
5
9
  import { deliverDirect } from "./direct-send.js";
6
10
  import { argumentSummary, commandPreview, formatProgress } from "./progress-format.js";
@@ -10,6 +14,9 @@ async function calledTool(harness, task) {
10
14
  return;
11
15
  // The registered native task owns this input shape and its branded assistant ID.
12
16
  const input = task.input;
17
+ // Nested calls belong to a parent tool; only model-issued calls get progress notices.
18
+ if (input.kind !== "model")
19
+ return;
13
20
  const entry = await harness.commit((tx) => tx.entry(input.assistant), BACKGROUND_CONTEXT);
14
21
  const message = entry?.model?.[0];
15
22
  if (message?.role !== "assistant")
@@ -17,6 +24,33 @@ async function calledTool(harness, task) {
17
24
  const call = message.content.find((part) => part.type === "toolCall" && part.id === input.callId);
18
25
  return call?.type === "toolCall" ? call : undefined;
19
26
  }
27
+ async function canonicalPath(path) {
28
+ return realpath(path).catch(async () => resolve(await realpath(dirname(path)).catch(() => dirname(path)), basename(path)));
29
+ }
30
+ /** Classify metadata only; attachment reads still execute and keep their normal results/history. */
31
+ async function attachmentPath(workingDir, path) {
32
+ if (typeof path !== "string")
33
+ return false;
34
+ // Match the native read tool's @, Unicode-space and home-path normalization.
35
+ let normalized = path.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
36
+ if (normalized.startsWith("@"))
37
+ normalized = normalized.slice(1);
38
+ if (normalized === "~" || normalized.startsWith("~/"))
39
+ normalized = resolve(homedir(), normalized.slice(2));
40
+ if (normalized.startsWith("file://")) {
41
+ try {
42
+ normalized = fileURLToPath(normalized);
43
+ }
44
+ catch {
45
+ /* Keep malformed URLs as ordinary paths, like native read. */
46
+ }
47
+ }
48
+ const root = resolve(workingDir, "attachments");
49
+ const candidate = resolve(workingDir, normalized);
50
+ const roots = [root, await canonicalPath(root)];
51
+ const candidates = [candidate, await canonicalPath(candidate)];
52
+ return roots.some((directory) => candidates.some((file) => file === directory || file.startsWith(`${directory}${sep}`)));
53
+ }
20
54
  /** Observe native admission and settlement; Durable owns execution and recovery. */
21
55
  export async function startProgress(harness, workingDir, sender) {
22
56
  // Existing live work belongs to the previous startup; do not replay historical progress.
@@ -27,9 +61,10 @@ export async function startProgress(harness, workingDir, sender) {
27
61
  if (task.kind === "pi.compaction" && (await takeScheduledCompaction(harness, task.id)))
28
62
  return;
29
63
  const settings = await readSettings(workingDir);
30
- if (!settings.progressMessages)
31
- return;
32
64
  const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
65
+ // Nothing can be enabled when the global setting is off and no chat chose /verbose on.
66
+ if (!settings.progressMessages && !chats.some((chat) => chat.progress))
67
+ return;
33
68
  const runs = (await harness.snapshot(Runs, BACKGROUND_CONTEXT))?.items ?? [];
34
69
  let conversationId = task.conversationId;
35
70
  let chatGuid;
@@ -46,9 +81,11 @@ export async function startProgress(harness, workingDir, sender) {
46
81
  parents.unshift(call?.name ?? owner?.kind ?? "child");
47
82
  conversationId = conversation.owner.conversationId;
48
83
  }
49
- if (!isReplyEnabled(settings, chatGuid))
84
+ if (!isReplyEnabled(settings, chatGuid) || !progressEnabled(settings, chats, chatGuid))
50
85
  return;
51
86
  const tool = await calledTool(harness, task);
87
+ if (tool?.name === "read" && (await attachmentPath(workingDir, tool.arguments.path)))
88
+ return;
52
89
  const request = tool
53
90
  ? {
54
91
  phase: "request",
@@ -71,7 +108,8 @@ export async function startProgress(harness, workingDir, sender) {
71
108
  if (!notice.chatGuid || !notice.request || task.state.status !== "terminal")
72
109
  return;
73
110
  const settings = await readSettings(workingDir);
74
- if (!settings.progressMessages || !isReplyEnabled(settings, notice.chatGuid))
111
+ const chats = (await harness.snapshot(Chats, BACKGROUND_CONTEXT))?.items ?? [];
112
+ if (!isReplyEnabled(settings, notice.chatGuid) || !progressEnabled(settings, chats, notice.chatGuid))
75
113
  return;
76
114
  const outcome = task.state.outcome;
77
115
  let success = outcome.status === "completed";
@@ -6,4 +6,5 @@ export const Deliveries = defineDoc({
6
6
  history: "latest",
7
7
  fork: "initial",
8
8
  initial: () => ({ answers: {} }),
9
+ checkpointWhen: () => false,
9
10
  });
package/dist/agent/run.js CHANGED
@@ -9,6 +9,7 @@ export const Runs = defineDoc({
9
9
  version: 1,
10
10
  scope: "session",
11
11
  initial: () => ({ items: [] }),
12
+ checkpointWhen: () => false,
12
13
  });
13
14
  /** Model-visible notes in the chat conversation about what happened in a run. */
14
15
  export const RUN_RECORD = "imessage.run-record";
@@ -103,7 +104,7 @@ export const RunExtension = defineExtension({
103
104
  if (run)
104
105
  run.endReason = args.reason;
105
106
  }, context);
106
- return { content: [{ type: "text", text: "The run ends after this answer." }] };
107
+ return { output: [{ type: "text", text: "The run ends after this answer." }] };
107
108
  },
108
109
  }),
109
110
  ],
@@ -11,12 +11,14 @@ export const Schedules = defineDoc({
11
11
  version: 1,
12
12
  scope: "session",
13
13
  initial: () => ({ items: [] }),
14
+ checkpointWhen: () => false,
14
15
  });
15
16
  export const RunRequests = defineDoc({
16
17
  kind: "imessage.schedule-requests",
17
18
  version: 1,
18
19
  scope: "session",
19
20
  initial: () => ({ items: [] }),
21
+ checkpointWhen: () => false,
20
22
  });
21
23
  function nextAt(schedule, previous, now) {
22
24
  if (schedule.time !== undefined)
@@ -91,13 +93,12 @@ export function schedulingExtension(harness, defaults) {
91
93
  phases: {
92
94
  async sleep(task, runtime, context) {
93
95
  const { at } = task.state.checkpoint;
94
- await runtime.sleep(at, context);
96
+ // A sleeping task resumes this phase at the same checkpoint once it is due.
97
+ if (runtime.now() < at)
98
+ return runtime.sleepUntil(at, task.state.checkpoint, context);
95
99
  // Due-now work must not start between the configuration commit and registry publication.
96
- if (!admissionReady) {
97
- await runtime.sleep(runtime.now() + 100, context);
98
- await runtime.commit(() => ({ status: "running", checkpoint: task.state.checkpoint }), context);
99
- return;
100
- }
100
+ if (!admissionReady)
101
+ return runtime.sleepUntil(runtime.now() + 100, task.state.checkpoint, context);
101
102
  await runtime.commit(async (tx) => {
102
103
  const schedule = (await tx.doc(Schedules)).items.find((item) => item.id === task.input.jobId);
103
104
  if (!schedule)
@@ -114,7 +115,7 @@ export function schedulingExtension(harness, defaults) {
114
115
  if (!task.input.requestId && beforeToday)
115
116
  return { status: "running", checkpoint: { phase: "sleep", at: nextAt(schedule, at, now) } };
116
117
  // All triggers of a job share its non-overlap boundary.
117
- for (const status of ["pending", "running", "waiting", "completing"]) {
118
+ for (const status of ["pending", "running", "waiting", "sleeping", "completing"]) {
118
119
  const existing = (await tx.scanTasks({ conversationId: schedule.conversationId, kind: EXECUTION_KIND, status }, 1)).items[0];
119
120
  if (existing)
120
121
  return {
@@ -312,7 +312,7 @@ export async function createBrowser(workingDir, agentDir) {
312
312
  executionMode: "sequential",
313
313
  async execute(args, api, context) {
314
314
  return {
315
- content: [{ type: "text", text: await run(api.conversationId, args, context.abortSignal) }],
315
+ output: [{ type: "text", text: await run(api.conversationId, args, context.abortSignal) }],
316
316
  };
317
317
  },
318
318
  }),
@@ -15,7 +15,7 @@ async function memoryCli(workingDir, args, signal) {
15
15
  });
16
16
  return stdout;
17
17
  }
18
- const text = (value) => ({ content: [{ type: "text", text: value }] });
18
+ const text = (value) => ({ output: [{ type: "text", text: value }] });
19
19
  /** Structured memory tools; the namespace list and core memory are read when the extension is built. */
20
20
  export async function memoryExtension(workingDir) {
21
21
  const namespaces = await memoryCli(workingDir, ["list-namespaces"])
@@ -25,7 +25,7 @@ export function messageExtension(messaging) {
25
25
  text: args.text,
26
26
  filePath: args.filePath,
27
27
  });
28
- return { content: [{ type: "text", text: JSON.stringify(receipt) }] };
28
+ return { output: [{ type: "text", text: JSON.stringify(receipt) }] };
29
29
  },
30
30
  }),
31
31
  ],
@@ -19,7 +19,7 @@ export const ImageRead = defineExtension({
19
19
  throw new Error("No image filesystem");
20
20
  const path = getOrThrow(await api.env.canonicalPath(args.path, context));
21
21
  return {
22
- content: [{ type: "text", text: imagePrefix + JSON.stringify(path) }],
22
+ output: [{ type: "text", text: imagePrefix + JSON.stringify(path) }],
23
23
  details: { imagePath: path },
24
24
  };
25
25
  },