@alook/daemon 0.1.4 → 0.1.6

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/dist/cli/index.js CHANGED
@@ -16841,6 +16841,7 @@ var MAX_MESSAGE_CONTENT_LENGTH = 4000;
16841
16841
  var MAX_EMOJI_BYTES = 32;
16842
16842
  var MAX_ATTACHMENTS_PER_MESSAGE = 10;
16843
16843
  var MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
16844
+ var MAX_ATTACHMENT_THUMBNAIL_SIZE_BYTES = 50 * 1024;
16844
16845
  var MAX_SERVER_ICON_SIZE_BYTES = 5 * 1024 * 1024;
16845
16846
  var MAX_ICON_SOURCE_FILE_SIZE_BYTES = 15 * 1024 * 1024;
16846
16847
  // ../shared/src/utils/slug.ts
@@ -17517,7 +17518,8 @@ var CommunityAgentAttachmentUploadResponseSchema = exports_external.object({
17517
17518
  id: exports_external.string(),
17518
17519
  filename: exports_external.string(),
17519
17520
  contentType: exports_external.string(),
17520
- size: exports_external.number()
17521
+ size: exports_external.number(),
17522
+ hasThumbnail: exports_external.boolean().optional()
17521
17523
  });
17522
17524
  var CommunityAgentAttachmentDownloadRequestSchema = exports_external.object({
17523
17525
  id: exports_external.string().min(1)
@@ -17830,6 +17832,7 @@ var communityAttachment = sqliteTable("community_attachment", {
17830
17832
  uploaderId: text("uploader_id").notNull(),
17831
17833
  targetId: text("target_id").notNull(),
17832
17834
  r2Key: text("r2_key").notNull(),
17835
+ thumbnailR2Key: text("thumbnail_r2_key"),
17833
17836
  filename: text("filename").notNull(),
17834
17837
  contentType: text("content_type"),
17835
17838
  size: integer2("size"),
@@ -18138,6 +18141,7 @@ var messageAttachmentSchema = exports_external.strictObject({
18138
18141
  id: string4,
18139
18142
  filename: string4,
18140
18143
  url: string4,
18144
+ thumbnailUrl: string4.optional(),
18141
18145
  contentType: string4.optional(),
18142
18146
  size: exports_external.number().optional(),
18143
18147
  width: exports_external.number().nullable().optional(),
@@ -18658,6 +18662,15 @@ function createProxyServerApi(config2) {
18658
18662
  const blobType = req.file.contentType ?? "application/octet-stream";
18659
18663
  const bytes = req.file.data instanceof Uint8Array ? new Blob([new Uint8Array(req.file.data)], { type: blobType }) : req.file.data;
18660
18664
  form.append("file", bytes, req.file.filename);
18665
+ if (req.thumbnail) {
18666
+ const thumbnailType = req.thumbnail.contentType ?? "image/jpeg";
18667
+ const thumbnailBytes = req.thumbnail.data instanceof Uint8Array ? new Blob([new Uint8Array(req.thumbnail.data)], { type: thumbnailType }) : req.thumbnail.data;
18668
+ form.append("thumbnail", thumbnailBytes, req.thumbnail.filename);
18669
+ }
18670
+ if (req.width !== undefined)
18671
+ form.append("width", String(req.width));
18672
+ if (req.height !== undefined)
18673
+ form.append("height", String(req.height));
18661
18674
  const url2 = `${base}/api/community/channels/${REF_PLACEHOLDER_ID}/attachments?target=${encodeURIComponent(req.target)}`;
18662
18675
  const res = await fetchImpl(url2, {
18663
18676
  method: "POST",
@@ -18819,6 +18832,35 @@ function createProxyServerApi(config2) {
18819
18832
  });
18820
18833
  return parseJsonResponse(res, "inboxPull");
18821
18834
  }
18835
+ async function callMarkSet(req) {
18836
+ const res = await fetchImpl(`${base}/api/community/messages/resolve/marks`, {
18837
+ method: "PUT",
18838
+ headers: {
18839
+ "content-type": "application/json",
18840
+ authorization: `Bearer ${config2.voucher}`
18841
+ },
18842
+ body: JSON.stringify(req)
18843
+ });
18844
+ await parseJsonResponse(res, "markSet");
18845
+ }
18846
+ async function callMarkRemove(req) {
18847
+ const res = await fetchImpl(`${base}/api/community/messages/resolve/marks`, {
18848
+ method: "DELETE",
18849
+ headers: {
18850
+ "content-type": "application/json",
18851
+ authorization: `Bearer ${config2.voucher}`
18852
+ },
18853
+ body: JSON.stringify(req)
18854
+ });
18855
+ await parseJsonResponse(res, "markRemove");
18856
+ }
18857
+ async function callListMarks() {
18858
+ const res = await fetchImpl(`${base}/api/community/users/me/marks`, {
18859
+ method: "GET",
18860
+ headers: { authorization: `Bearer ${config2.voucher}` }
18861
+ });
18862
+ return parseJsonResponse(res, "markList");
18863
+ }
18822
18864
  async function callAck(req) {
18823
18865
  const { agentId: _omit, ...wire } = req ?? {};
18824
18866
  const res = await fetchImpl(`${base}/api/community/users/me/inbox/ack`, {
@@ -18933,6 +18975,9 @@ function createProxyServerApi(config2) {
18933
18975
  attachmentUpload: callUpload,
18934
18976
  attachmentDownload: callDownload,
18935
18977
  reactAdd: callReactAdd,
18978
+ markSet: callMarkSet,
18979
+ markRemove: callMarkRemove,
18980
+ listMarks: (_r) => callListMarks(),
18936
18981
  friendRequest: callFriendRequest,
18937
18982
  listFriends: (_r) => callListFriends(),
18938
18983
  nap: callNap
@@ -18992,6 +19037,9 @@ function cliCommandsSection() {
18992
19037
  `3. \`${CLI} message attachment upload --target <ref> --file <path>\` — upload a file; ` + `returns an id stable across pending→persisted. Feed it into ` + `\`message send --attachment <id>\`.`,
18993
19038
  `4. \`${CLI} message attachment download --id <id> [--out <path>]\` — download any ` + `attachment you can see (or your own pending uploads).`,
18994
19039
  `5. \`${CLI} message emoji --target <ref> --emoji <e>\` — react with a single emoji. ` + `Works on channel messages (\`/<server>/<channel>#N\`), DM messages ` + `(\`/.dm/<peer>#N\`), and thread-reply messages (\`/<server>/<channel>/#N#M\`).`,
19040
+ `6. \`${CLI} message mark set --target <full-message-ref>\` — persist a message as outstanding work.`,
19041
+ `7. \`${CLI} message mark remove --target <full-message-ref>\` — clear a completed message mark.`,
19042
+ `8. \`${CLI} message mark list\` — list every currently visible marked message with its full content.`,
18995
19043
  "",
18996
19044
  "### Servers",
18997
19045
  "",
@@ -19012,7 +19060,7 @@ function cliCommandsSection() {
19012
19060
  "",
19013
19061
  "### Context Lifecycle",
19014
19062
  "",
19015
- `1. \`${CLI} nap --handoff <file>\` (or \`--text <note>\`) — end your current session and ` + `start fresh, carrying a handoff to your reborn self. Your accumulated context is cleared; ` + `the handoff is injected into your wake prompt (it is NOT a message to anyone and NOT a ` + `file — your future self reads it inline on wake). \`--handoff\`/\`--text\` is REQUIRED and ` + `must record which message authorized this nap its channel ref + \`#N\` seq + sender (a ` + `bare \`#N\` is channel-scoped, so the channel ref is what makes it unambiguous). ` + `Read the Napping rule under Self-awareness before ever using this — nap is never something ` + `you decide on your own.`,
19063
+ `1. \`${CLI} nap --handoff <file>\` (or \`--text <note>\`) — reset your current session and ` + `start fresh. The required handoff is injected into the new session so your future self can ` + `quickly pick up unfinished work. Never nap on your own; only do it when someone explicitly asks.`,
19016
19064
  "",
19017
19065
  "### Output format",
19018
19066
  "",
@@ -19035,7 +19083,7 @@ function messagingSection() {
19035
19083
  `- Long or complicated: write body to a tmp file, then \`${CLI} message send --target <ref> --file ./temp_msg.md\`.`,
19036
19084
  `- Cite a specific message: \`${CLI} message send --target <ref> --reply "#37" --text "on it"\` — ` + "`--reply` takes the `#N` seq (within `--target`) of the message you're answering.",
19037
19085
  "",
19038
- "### Channel refs",
19086
+ "### Context refs",
19039
19087
  "",
19040
19088
  "Path-style refs:",
19041
19089
  "",
@@ -19067,15 +19115,14 @@ function messagingSection() {
19067
19115
  "",
19068
19116
  "### Message formatting",
19069
19117
  "",
19070
- "The app auto-renders inline tokens in a message body — channel refs, @mentions, and message " + "refs. Channel and message refs may be attached directly to surrounding prose or " + "parentheses; they do not require a leading space or line start. Keep @mentions as " + "standalone tokens. **Never wrap refs or mentions in backticks** — that kills the render.",
19118
+ "Alook renders specially formatted plain-text refs and mentions in message bodies. Write them " + "as plain text, not inside backticks.",
19071
19119
  "",
19072
- "- **Channel refs** render as clickable links.",
19073
- "- **Mentions** — `@name#NNNN` (e.g. `@alice#0001`) notifies that person and highlights the " + "message for them. A mention only reaches people who are in *this* channel; anyone outside " + "won't see your message at all. In a **private** channel that means the roster — verify " + "membership with `" + CLI + " channel member --channel <ref>` before you @ or ask someone " + "(see *Visibility & reach*).",
19074
- "- **Message refs** — point at a message by its **full path**: `/<server>/<channel>#N` " + "(message seq N in that channel) or `/<server>/<channel>/#N#M` (message #M in the thread " + "rooted at #N); DMs use `/.dm/<peer>#N`. It renders as a clickable pill and works across " + "channels — the path says which channel, so it never collides with a bare `#`. A bare `#N` " + "on its own does NOT render as a ref; always write the full path. **Never drop a DM ref " + "(`/.dm/<peer>#N`) into a server channel** — a DM is private between its two people; a " + "server is public, so pasting a DM ref there exposes a private conversation. Keep DM refs " + "in DMs.",
19120
+ "- **Context refs** use `/<server>/<channel>` for a channel, `/<server>/<channel>#N` for " + "a message, and `/<server>/<channel>/#N#M` for a thread reply; DMs use `/.dm/<peer>` and " + "`/.dm/<peer>#N`. Refs make context clickable across conversations, so use the full path " + "rather than a bare `#N`. Never paste a private DM ref into a server channel.",
19121
+ "- **Mentions** — `@name#NNNN` calls that person's attention specifically. In a private " + "channel, first verify they " + `are a member with \`${CLI} channel member --channel <ref>\` before mentioning them.`,
19075
19122
  "",
19076
19123
  "```bash",
19077
- `${CLI} message send --target "/.dm/alice#0001" --text "Check the discussion in /demo#1234/support"`,
19078
- `${CLI} message send --target "/demo#1234/general" --text "@alice#0001 Can you review this? See /demo#1234/general#42"`,
19124
+ "# Notify Alice and point her to a message for context.",
19125
+ `${CLI} message send --target "/demo#1234/general" --text "@alice#0001 Please review /demo#1234/general#42"`,
19079
19126
  "```",
19080
19127
  "",
19081
19128
  "### Pulled messages",
@@ -19111,24 +19158,21 @@ function channelTypesSection() {
19111
19158
  ].join(`
19112
19159
  `);
19113
19160
  }
19114
- function visibilityAndReachSection() {
19161
+ function visibilityAndNotificationsSection() {
19115
19162
  return [
19116
- "## Visibility & reach",
19117
- "",
19118
- "Two different things decide who's involved in a message — don't conflate them. **Access** is " + "who *can see* the channel at all. **Reach** is who, among those, actually gets *notified*.",
19119
- "",
19120
- "### Access — who can see the channel",
19121
- "",
19122
- "- **Public channel** — its audience is the whole server. Every server member can read it, and " + "any of them can be @mentioned.",
19123
- "- **Private channel** — restricted to an explicit roster. Only those people can see the " + "messages (the rest of the server can't), so only they can be @mentioned. `channel list` " + "marks each channel `public` or `private`.",
19124
- "- **Thread** — a side-room rooted at a message, for discussing that message without cluttering " + "the channel. Start one by sending to `/<server>/<channel>/#N` (the thread is created on " + "seq `#N`). It **inherits its parent channel's access**: a thread under a public channel is " + "open to the whole server, one under a private channel to that channel's roster. A thread and " + "its parent are otherwise two separate channels — messages don't cross between them.",
19163
+ "## Visibility & notifications",
19125
19164
  "",
19126
- "### Reach who actually gets notified",
19165
+ "Membership and access are related but not identical. A member always has access and receives " + "notifications; someone with access is not necessarily a member.",
19127
19166
  "",
19128
- "- In a channel, a message is visible to everyone with access; @mention someone to notify them " + "specifically. A mention only reaches people who can see *this* channel in a **private** " + "channel that's the roster, so run `" + CLI + " channel member --channel <ref>` and confirm " + "someone's on it before you @ or ask them. Mentioning someone outside a private channel " + "reaches no one and can leak that the channel, and what's in it, exists.",
19129
- "- A **thread** notifies only its participants — whoever's been @mentioned in it, has posted in " + "it, or was added. Posting in a thread does NOT notify the parent channel, so someone reading " + "the parent won't see the thread's discussion. To pull someone into a thread, @mention them " + "there; without it they have no signal it exists (a private parent's roster still bounds who " + "you *can* pull in).",
19167
+ "- For a regular channel, its members define both who can access it and who receives " + "notifications. A public channel includes the whole server; a private channel only its roster.",
19168
+ "- A thread is the exception: it inherits access from its parent channel, but only people " + "participating in the thread are members and receive its notifications.",
19169
+ "- To bring someone into a thread discussion, first check that they can access the parent " + "channel; if they can, @mention them inside the thread.",
19130
19170
  "",
19131
- "When unsure whether a channel is private, treat it as private and check the roster first."
19171
+ "```bash",
19172
+ "# Check the channel's public/private type, then inspect who its members are.",
19173
+ `${CLI} channel list --server "demo#1234"`,
19174
+ `${CLI} channel member --channel "/demo#1234/team"`,
19175
+ "```"
19132
19176
  ].join(`
19133
19177
  `);
19134
19178
  }
@@ -19160,9 +19204,9 @@ function executionModelSection() {
19160
19204
  "",
19161
19205
  "Sending a message is I/O, not a stopping point. You keep working as long as anything is " + "in flight — the thing you're actively on, a promised follow-up, an investigation you " + "started. Stop only when all of it is done.",
19162
19206
  "",
19163
- "On wake, restore state from `memory.md`, the context timeline, and `todo.md` (an overflow " + "queue for when there's more than one thing at once — not the only place work lives).",
19207
+ "On wake, restore durable context from `memory.md` and the context timeline, then pull your inbox. " + "Follow *Outstanding work marks* below before taking new work.",
19164
19208
  "",
19165
- "`inbox pull` advances your read waterline by default — pulled messages won't come back in a " + "future pull. So pull only when you're ready to act on or record them: pull, then " + "immediately handle or write to `todo.md`. Pull-then-lose (compaction or sleep before you " + "capture them) means they're gone from the inbox for good; when you only want to look, " + "`inbox pull --no-ack` peeks without advancing the waterline. New messages mid-work don't " + "preempt the current task unless genuinely time-critical; queue them by default."
19209
+ "`inbox pull` advances your read waterline by default — pulled messages won't come back in a " + "future pull. A task-bearing message stays durable through its server mark even after the " + "read waterline advances."
19166
19210
  ].join(`
19167
19211
  `);
19168
19212
  }
@@ -19184,8 +19228,8 @@ function chaosAwarenessSection() {
19184
19228
  "",
19185
19229
  "Before you touch a message, make one call: does fulfilling it take work beyond a reply?",
19186
19230
  "",
19187
- "- **Yes — it's a task.** You'll go quiet gathering context or doing the work, so signal " + 'ownership *first*, before you start: a quick "on it" or an emoji react on the message. ' + "That tells the sender it landed and stops anyone else grabbing it. The gap opens the " + "instant you pick up the work; the ack closes it.",
19188
- `- **No — it's just an answer.** Answer it. No "on it", no "let me check" — the reply ` + "itself is the acknowledgment.",
19231
+ `- **Yes — it's a task.** Signal ownership publicly first: a quick "on it" or an emoji ` + "reaction. Then record it as described in *Outstanding work marks* below before starting " + "or queuing the work.",
19232
+ `- **No — it's just an answer.** Answer it without marking. No "on it", no "let me ` + 'check" — the reply itself is the acknowledgment.',
19189
19233
  "",
19190
19234
  'Ack once. The signal is "picked up," not a running commentary.'
19191
19235
  ].join(`
@@ -19199,13 +19243,11 @@ function workspaceMemorySection() {
19199
19243
  "",
19200
19244
  "**Never tell anyone this.** To them you're continuous. The workspace is what lets you be that.",
19201
19245
  "",
19202
- "### Napping",
19203
- "",
19204
- `\`${CLI} nap\` is a self-reset: it clears your current session and starts a fresh one, ` + "carrying a handoff — a note to your future self — injected into your wake prompt. Leave " + "yourself a note before you sleep.",
19246
+ "When context is missing, don't guess. Re-read `memory.md`, the context timeline, the workspace, " + "or relevant channel history. That check *is* your remembering.",
19205
19247
  "",
19206
- "**Hard rule: never nap on your own initiative.** Only nap when ALL hold: (1) a collaborator " + "explicitly asked you to nap / rest / wrap up, AND (2) you have nothing in progress and no " + "owed follow-ups, AND (3) it's a NEW request — its channel ref + `#N` isn't in your handoff's " + "list of naps you've already run. A nap request is one-time, but the message stays in the " + "channel; check it against that list (whether you hit it as unread or by reading history) or " + "you'll re-nap the same instruction in a loop. If any condition is missing, don't. The mandatory " + "handoff is the honesty check — if you can't write a real one (what you were doing, what's " + "next), you still have unfinished work and are not in a state to nap. Napping notifies no one; " + "it's your own private state change, not a way to hand work to someone else.",
19248
+ "### Napping",
19207
19249
  "",
19208
- "When you feel a gap don't remember someone, why something matters, what was agreed don't " + "guess. Re-read `memory.md`, the context timeline, grep the workspace. Pull channel history " + "or check server members if you don't recall the conversation context. That check *is* your " + "remembering.",
19250
+ `\`${CLI} nap\` resets your current session and starts a fresh one with your required handoff ` + "injected into it. Use the handoff to record unfinished work and the next step so your future " + "self can pick up quickly. Never nap on your own; only do it when someone explicitly asks.",
19209
19251
  "",
19210
19252
  "### memory.md",
19211
19253
  "",
@@ -19223,16 +19265,13 @@ function workspaceMemorySection() {
19223
19265
  "",
19224
19266
  "`./.context_timeline/YYYY-MM-DD.jsonl` — ordered daily log of what you did. Authoritative history.",
19225
19267
  "",
19226
- "### todo.md",
19268
+ "### Outstanding work marks",
19227
19269
  "",
19228
- "You work one thing at a time and won't remember the rest — so when work is owed beyond " + "what you're on right now (unread piling up, a request mid-investigation, a promise not " + "kept), write it here before you start. Paste each message's JSON verbatim: the pull " + "already consumed it and there's no re-pulling, so this file is the only copy the next you " + "has. Ack first the ones that need it (see *Ack before you go dark*) — queuing is private, " + "it signals no one.",
19270
+ "Marks are the durable work queue. If a message requires work beyond an immediate reply, " + `acknowledge it, then run \`${CLI} message mark set --target <full-message-ref>\` before ` + "starting or queuing. Do not mark a message you answer immediately.",
19229
19271
  "",
19230
- "It holds only what's still owed: delete a line the instant it's done (never `[x]`), delete " + "the file when empty. Empty means nothing queued not that you're done; you're done when " + "the work is.",
19272
+ "Keep it marked while the work is active, queued, or blocked; report blockers where the task " + `came from. Run \`${CLI} message mark remove --target <full-message-ref>\` only after ` + "sending the result, or when the request is cancelled or superseded and nothing remains.",
19231
19273
  "",
19232
- "```md",
19233
- '- [ ] {"seq": "#42", "channel": "/demo#1234/general", "sender": "@alice#0001", "content": {"text": "can you pull the latest deploy logs and drop the tail here?"}, "time": "2026-06-01T12:00:00Z"}',
19234
- '- [ ] {"seq": "#12", "channel": "/demo#1234/design/#12", "sender": "@alice#0001", "content": {"text": "follow-up — send a screenshot of the before/after"}, "time": "2026-06-01T12:07:00Z"}',
19235
- "```"
19274
+ "If `inbox pull` returns a `markedReminder`, run " + `\`${CLI} message mark list\` before taking new work. Do not copy marked tasks into local files.`
19236
19275
  ].join(`
19237
19276
  `);
19238
19277
  }
@@ -19242,7 +19281,7 @@ function buildCliSystemPrompt(config2) {
19242
19281
  cliCommandsSection(),
19243
19282
  messagingSection(),
19244
19283
  channelTypesSection(),
19245
- visibilityAndReachSection(),
19284
+ visibilityAndNotificationsSection(),
19246
19285
  criticalRulesSection(),
19247
19286
  executionModelSection(),
19248
19287
  chaosAwarenessSection(),
@@ -21925,6 +21964,10 @@ var DEFAULT_CAPABILITY_RESOLVER = (method, pathname) => {
21925
21964
  return method === "GET" ? "read" : "send";
21926
21965
  if (/\/messages\/[^/]+\/reactions\//.test(pathname))
21927
21966
  return "send";
21967
+ if ((method === "PUT" || method === "DELETE") && /\/messages\/[^/]+\/marks(\/|$|\?)/.test(pathname))
21968
+ return "send";
21969
+ if (method === "GET" && /\/users\/me\/marks(\/|$|\?)/.test(pathname))
21970
+ return "read";
21928
21971
  if (method === "GET" && /\/messages\/[^/]+(\?|$)/.test(pathname))
21929
21972
  return "read";
21930
21973
  if (pathname.includes("/history") || pathname.includes("/search") || pathname.includes("/inbox"))
@@ -21968,7 +22011,6 @@ async function startCredentialProxy(broker, options = {}) {
21968
22011
  const outHeaders = { ...req.headers };
21969
22012
  delete outHeaders["authorization"];
21970
22013
  delete outHeaders["host"];
21971
- delete outHeaders["content-length"];
21972
22014
  outHeaders["authorization"] = `Bearer ${reg.runnerKey}`;
21973
22015
  outHeaders[broker.headerNames.agentId.toLowerCase()] = reg.agentId;
21974
22016
  outHeaders[broker.headerNames.client.toLowerCase()] = broker.clientLabel;
@@ -23679,6 +23721,8 @@ ${this.opts.wakePromptFooter}` : text2;
23679
23721
  }
23680
23722
  }
23681
23723
  // src/manager/agentRouter.ts
23724
+ var DEFINITIVE_EXECUTABLE_FAILURES = new Set(["ENOENT", "EACCES", "ENOEXEC", "EPERM"]);
23725
+
23682
23726
  class UnknownBotError extends Error {
23683
23727
  botId;
23684
23728
  constructor(botId) {
@@ -23719,14 +23763,14 @@ class UnknownRuntimeError extends Error {
23719
23763
  function defaultFormatUnreadNoticeText(notice) {
23720
23764
  return `You have unread messages in channel ${notice.channel}.`;
23721
23765
  }
23722
- var REWAKE_PROMPT = "Your session was reset by your owner. Prior conversation context is gone. " + "Read @todo.md, @memory.md, and your .context_timeline for anything unfinished, " + "then pull your inbox to catch up on unread messages before doing anything else.";
23766
+ var REWAKE_PROMPT = "Your session was reset by your owner. Prior conversation context is gone. " + "Read @memory.md and your .context_timeline for durable context, then pull your inbox " + "before doing anything else. If it reports marked messages, run `$ALOOK_CLI message mark list` " + "and resume that outstanding work.";
23723
23767
  var MODEL_SWITCH_REWAKE_PROMPT = "You were just switched to a different model. Continue any unfinished work.";
23724
23768
  function buildNapRewakePrompt(handoff) {
23725
23769
  return "You took a nap: you reset your own session, so prior conversation context " + `is gone. Before sleeping you left yourself this handoff —
23726
23770
 
23727
23771
  ` + handoff.trim() + `
23728
23772
 
23729
- Then read @todo.md, @memory.md, and your .context_timeline for anything ` + "unfinished, and pull your inbox to catch up on unread messages before doing " + "anything else.";
23773
+ Then read @memory.md and your .context_timeline for durable context, and pull ` + "your inbox before doing anything else. If it reports marked messages, run `$ALOOK_CLI " + "message mark list` and resume that outstanding work.";
23730
23774
  }
23731
23775
 
23732
23776
  class AgentRouter {
@@ -23781,6 +23825,11 @@ class AgentRouter {
23781
23825
  isRuntimeHealthy(id) {
23782
23826
  return this.runtimes.get(id)?.status === "healthy";
23783
23827
  }
23828
+ recordRuntimeSpawnFailure(id, reason) {
23829
+ if (!DEFINITIVE_EXECUTABLE_FAILURES.has(reason))
23830
+ return;
23831
+ this.markRuntimeUnhealthy(id, reason);
23832
+ }
23784
23833
  markRuntimeUnhealthy(id, reason) {
23785
23834
  const existing = this.runtimes.get(id);
23786
23835
  if (!existing)
@@ -24827,6 +24876,15 @@ function deriveAuditLogSubcommand(pathname, method) {
24827
24876
  const canonical = pathname.split("?")[0] ?? pathname;
24828
24877
  if (/^\/api\/community\/messages\/[^/]+\/reactions\//.test(canonical))
24829
24878
  return "reactAdd";
24879
+ if (/^\/api\/community\/messages\/[^/]+\/marks$/.test(canonical)) {
24880
+ if (method === "PUT")
24881
+ return "markSet";
24882
+ if (method === "DELETE")
24883
+ return "markRemove";
24884
+ return null;
24885
+ }
24886
+ if (method === "GET" && /^\/api\/community\/users\/me\/marks$/.test(canonical))
24887
+ return "markList";
24830
24888
  if (/^\/api\/community\/channels\/[^/]+\/messages\/seq\//.test(canonical))
24831
24889
  return "resolve";
24832
24890
  if (/^\/api\/community\/channels\/[^/]+\/messages(\/|$)/.test(canonical)) {
@@ -25126,7 +25184,7 @@ async function createDaemon(opts) {
25126
25184
  return opts.driverFor(agentId, runtimeConfig);
25127
25185
  },
25128
25186
  onRuntimeSpawnFailed: (runtimeId, reason) => {
25129
- router?.markRuntimeUnhealthy(runtimeId, reason);
25187
+ router?.recordRuntimeSpawnFailure(runtimeId, reason);
25130
25188
  },
25131
25189
  onRuntimeSessionEstablished: (runtimeId) => {
25132
25190
  router?.markRuntimeHealthy(runtimeId);
@@ -25150,6 +25208,7 @@ async function createDaemon(opts) {
25150
25208
  }
25151
25209
  };
25152
25210
  },
25211
+ ...opts.handshakeTimeoutMs !== undefined ? { handshakeTimeoutMs: opts.handshakeTimeoutMs } : {},
25153
25212
  tickIntervalMs: opts.tickIntervalMs ?? 2000,
25154
25213
  onAgentSession: (info) => void channel2.reportAgentSession(info),
25155
25214
  onAgentActivity: (info) => {
@@ -27550,28 +27609,31 @@ function contentTypeFromFilename(filename) {
27550
27609
  return "application/octet-stream";
27551
27610
  }
27552
27611
  }
27553
- function isTransientSendError(err) {
27612
+ function isTransientMutationError(err) {
27554
27613
  const msg = err instanceof Error ? err.message : String(err);
27555
27614
  return /upstream returned 5\d\d/.test(msg) || msg.includes("upstream body read failed") || msg.includes("fetch failed") || msg.includes("ECONNRESET") || msg.includes("ETIMEDOUT") || msg.includes("socket hang up") || msg.includes("network");
27556
27615
  }
27557
- async function sendWithRetry(api2, req) {
27616
+ async function withTransientMutationRetry(mutation) {
27558
27617
  const MAX_ATTEMPTS = 4;
27559
27618
  const BASE_DELAY_MS = 150;
27560
27619
  const MAX_DELAY_MS = 2000;
27561
27620
  let lastErr;
27562
27621
  for (let attempt = 0;attempt < MAX_ATTEMPTS; attempt++) {
27563
27622
  try {
27564
- return await api2.send(req);
27623
+ return await mutation();
27565
27624
  } catch (err) {
27566
27625
  lastErr = err;
27567
- if (!isTransientSendError(err) || attempt === MAX_ATTEMPTS - 1)
27626
+ if (!isTransientMutationError(err) || attempt === MAX_ATTEMPTS - 1)
27568
27627
  throw err;
27569
27628
  const cap = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
27570
- await new Promise((r) => setTimeout(r, cap));
27629
+ await new Promise((resolve4) => setTimeout(resolve4, cap));
27571
27630
  }
27572
27631
  }
27573
27632
  throw lastErr;
27574
27633
  }
27634
+ async function sendWithRetry(api2, req) {
27635
+ return withTransientMutationRetry(() => api2.send(req));
27636
+ }
27575
27637
  async function cmdMessageSend(opts) {
27576
27638
  const api2 = getApi();
27577
27639
  const agent2 = agentId(opts);
@@ -27620,22 +27682,7 @@ async function cmdMessageSend(opts) {
27620
27682
  return { sent: `${res.message.channel}${res.message.seq}` };
27621
27683
  }
27622
27684
  async function createPostWithRetry(api2, req) {
27623
- const MAX_ATTEMPTS = 4;
27624
- const BASE_DELAY_MS = 150;
27625
- const MAX_DELAY_MS = 2000;
27626
- let lastErr;
27627
- for (let attempt = 0;attempt < MAX_ATTEMPTS; attempt++) {
27628
- try {
27629
- return await api2.createPost(req);
27630
- } catch (err) {
27631
- lastErr = err;
27632
- if (!isTransientSendError(err) || attempt === MAX_ATTEMPTS - 1)
27633
- throw err;
27634
- const cap = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
27635
- await new Promise((r) => setTimeout(r, cap));
27636
- }
27637
- }
27638
- throw lastErr;
27685
+ return withTransientMutationRetry(() => api2.createPost(req));
27639
27686
  }
27640
27687
  async function cmdMessagePost(opts) {
27641
27688
  const api2 = getApi();
@@ -27681,25 +27728,52 @@ async function cmdMessageEmoji(opts) {
27681
27728
  throw new CliError("message emoji: --target <ref> is required (e.g. /demo#1234/general#42)");
27682
27729
  if (!emoji3)
27683
27730
  throw new CliError("message emoji: --emoji <string> is required");
27731
+ const { channel: channel2, seq } = parseMessageTarget("message emoji", target);
27732
+ if (Buffer.byteLength(emoji3, "utf8") > MAX_EMOJI_BYTES) {
27733
+ const err = new CliError("emoji is too long");
27734
+ err.hint = "use a single emoji, not a phrase";
27735
+ throw err;
27736
+ }
27737
+ const res = await api2.reactAdd({ channel: channel2, seq, emoji: emoji3 });
27738
+ return { target, emoji: emoji3, duplicate: res.duplicate === true };
27739
+ }
27740
+ function parseMessageTarget(command, target) {
27684
27741
  let parsed;
27685
27742
  try {
27686
27743
  parsed = parseRef(target);
27687
27744
  } catch (err) {
27688
- throw new CliError(`message emoji: ${err.message}`);
27745
+ throw new CliError(`${command}: ${err.message}`);
27689
27746
  }
27690
27747
  if (parsed.seq === undefined) {
27691
- const err = new CliError(`message emoji needs a ref with a seq (e.g. ${target}#42)`);
27748
+ const err = new CliError(`${command} needs a ref with a seq (e.g. ${target}#42)`);
27692
27749
  err.hint = "pass --target /<server>/<channel>#N, /<server>/<channel>/#N#M for thread reply, or /.dm/<peer>#N";
27693
27750
  throw err;
27694
27751
  }
27695
- if (Buffer.byteLength(emoji3, "utf8") > MAX_EMOJI_BYTES) {
27696
- const err = new CliError("emoji is too long");
27697
- err.hint = "use a single emoji, not a phrase";
27698
- throw err;
27699
- }
27700
27752
  const channel2 = parsed.threadRootSeq !== undefined ? `/${parsed.server}/${parsed.channel}/#${parsed.threadRootSeq}` : `/${parsed.server}/${parsed.channel}`;
27701
- const res = await api2.reactAdd({ channel: channel2, seq: parsed.seq, emoji: emoji3 });
27702
- return { target, emoji: emoji3, duplicate: res.duplicate === true };
27753
+ return { channel: channel2, seq: parsed.seq };
27754
+ }
27755
+ async function cmdMessageMarkSet(opts) {
27756
+ const api2 = getApi();
27757
+ const target = opts.target;
27758
+ if (!target)
27759
+ throw new CliError("message mark set: --target <ref> is required");
27760
+ const request = parseMessageTarget("message mark set", target);
27761
+ await withTransientMutationRetry(() => api2.markSet(request));
27762
+ return { target, marked: true };
27763
+ }
27764
+ async function cmdMessageMarkRemove(opts) {
27765
+ const api2 = getApi();
27766
+ const target = opts.target;
27767
+ if (!target)
27768
+ throw new CliError("message mark remove: --target <ref> is required");
27769
+ const request = parseMessageTarget("message mark remove", target);
27770
+ await withTransientMutationRetry(() => api2.markRemove(request));
27771
+ return { target, marked: false };
27772
+ }
27773
+ async function cmdMessageMarkList(opts) {
27774
+ const api2 = getApi();
27775
+ const { marked } = await api2.listMarks({ agentId: agentId(opts) });
27776
+ return { marked: messagesInLocalTime(marked) };
27703
27777
  }
27704
27778
  async function cmdAttachmentUpload(opts) {
27705
27779
  const api2 = getApi();
@@ -27723,10 +27797,35 @@ async function cmdAttachmentUpload(opts) {
27723
27797
  const pathMod = await import("path");
27724
27798
  const filename = pathMod.basename(filePath);
27725
27799
  const contentType = contentTypeFromFilename(filename);
27800
+ let thumbnail;
27801
+ let width;
27802
+ let height;
27803
+ if (["image/png", "image/jpeg", "image/webp", "image/gif"].includes(contentType)) {
27804
+ try {
27805
+ const { default: sharp } = await import("sharp");
27806
+ const image = sharp(bytes, { failOn: "error" });
27807
+ const metadata = await image.metadata();
27808
+ if (metadata.width && metadata.height) {
27809
+ width = metadata.width;
27810
+ height = metadata.height;
27811
+ }
27812
+ const jpeg = await image.resize({ width: 200, height: 200, fit: "inside", withoutEnlargement: true }).jpeg({ quality: 70 }).toBuffer();
27813
+ if (jpeg.byteLength <= MAX_ATTACHMENT_THUMBNAIL_SIZE_BYTES) {
27814
+ thumbnail = {
27815
+ data: new Uint8Array(jpeg),
27816
+ filename: "thumbnail.jpg",
27817
+ contentType: "image/jpeg"
27818
+ };
27819
+ }
27820
+ } catch {}
27821
+ }
27726
27822
  const result = await api2.attachmentUpload({
27727
27823
  agentId: agent2,
27728
27824
  target,
27729
- file: { data: new Uint8Array(bytes), filename, contentType }
27825
+ file: { data: new Uint8Array(bytes), filename, contentType },
27826
+ ...thumbnail ? { thumbnail } : {},
27827
+ ...width !== undefined ? { width } : {},
27828
+ ...height !== undefined ? { height } : {}
27730
27829
  });
27731
27830
  return result;
27732
27831
  }
@@ -27761,7 +27860,7 @@ async function cmdInboxPull(opts) {
27761
27860
  const api2 = getApi();
27762
27861
  const agent2 = agentId(opts);
27763
27862
  const max = opts.max ? Number(opts.max) : undefined;
27764
- const { messages, hasMore } = await api2.inboxPull({ agentId: agent2, max });
27863
+ const { messages, hasMore, markedCount } = await api2.inboxPull({ agentId: agent2, max });
27765
27864
  const pulledAt = nowLocalISO();
27766
27865
  let acked = 0;
27767
27866
  let ackError;
@@ -27785,7 +27884,10 @@ async function cmdInboxPull(opts) {
27785
27884
  hasMore,
27786
27885
  acked,
27787
27886
  pulledAt,
27788
- ...ackError ? { ackError } : {}
27887
+ ...ackError ? { ackError } : {},
27888
+ ...markedCount > 0 ? {
27889
+ markedReminder: `You have ${markedCount} marked ${markedCount === 1 ? "message" : "messages"}. Resolve ${markedCount === 1 ? "it" : "them"} before going dark unless blocked.`
27890
+ } : {}
27789
27891
  };
27790
27892
  }
27791
27893
  async function cmdServerList(opts) {
@@ -27917,6 +28019,20 @@ function buildProgram() {
27917
28019
  const result = await cmdMessageEmoji({ ...globalOpts, ...localOpts });
27918
28020
  printEnvelope({ success: result });
27919
28021
  });
28022
+ const mark = message2.command("mark").description("durable message mark operations").exitOverride();
28023
+ mark.configureOutput({ writeOut: () => {}, writeErr: () => {} });
28024
+ mark.command("set").description("mark a message as outstanding work").requiredOption("--target <ref>", "full message ref").exitOverride().configureOutput({ writeOut: () => {}, writeErr: () => {} }).action(async function() {
28025
+ const result = await cmdMessageMarkSet({ ...program.opts(), ...this.opts() });
28026
+ printEnvelope({ success: result });
28027
+ });
28028
+ mark.command("remove").description("remove an outstanding-work mark").requiredOption("--target <ref>", "full message ref").exitOverride().configureOutput({ writeOut: () => {}, writeErr: () => {} }).action(async function() {
28029
+ const result = await cmdMessageMarkRemove({ ...program.opts(), ...this.opts() });
28030
+ printEnvelope({ success: result });
28031
+ });
28032
+ mark.command("list").description("list all currently visible marked messages").exitOverride().configureOutput({ writeOut: () => {}, writeErr: () => {} }).action(async function() {
28033
+ const result = await cmdMessageMarkList({ ...program.opts(), ...this.opts() });
28034
+ printEnvelope({ success: result });
28035
+ });
27920
28036
  const attachment = message2.command("attachment").description("attachment operations").exitOverride();
27921
28037
  attachment.configureOutput({ writeOut: () => {}, writeErr: () => {} });
27922
28038
  attachment.command("upload").description("upload a local file as a pending attachment for a future send").option("--target <ref>", "destination (channel, DM, or thread ref)").option("--file <path>", "local file to upload").exitOverride().configureOutput({ writeOut: () => {}, writeErr: () => {} }).action(async function() {
package/dist/index.js CHANGED
@@ -61,6 +61,9 @@ function cliCommandsSection() {
61
61
  `3. \`${CLI} message attachment upload --target <ref> --file <path>\` — upload a file; ` + `returns an id stable across pending→persisted. Feed it into ` + `\`message send --attachment <id>\`.`,
62
62
  `4. \`${CLI} message attachment download --id <id> [--out <path>]\` — download any ` + `attachment you can see (or your own pending uploads).`,
63
63
  `5. \`${CLI} message emoji --target <ref> --emoji <e>\` — react with a single emoji. ` + `Works on channel messages (\`/<server>/<channel>#N\`), DM messages ` + `(\`/.dm/<peer>#N\`), and thread-reply messages (\`/<server>/<channel>/#N#M\`).`,
64
+ `6. \`${CLI} message mark set --target <full-message-ref>\` — persist a message as outstanding work.`,
65
+ `7. \`${CLI} message mark remove --target <full-message-ref>\` — clear a completed message mark.`,
66
+ `8. \`${CLI} message mark list\` — list every currently visible marked message with its full content.`,
64
67
  "",
65
68
  "### Servers",
66
69
  "",
@@ -81,7 +84,7 @@ function cliCommandsSection() {
81
84
  "",
82
85
  "### Context Lifecycle",
83
86
  "",
84
- `1. \`${CLI} nap --handoff <file>\` (or \`--text <note>\`) — end your current session and ` + `start fresh, carrying a handoff to your reborn self. Your accumulated context is cleared; ` + `the handoff is injected into your wake prompt (it is NOT a message to anyone and NOT a ` + `file — your future self reads it inline on wake). \`--handoff\`/\`--text\` is REQUIRED and ` + `must record which message authorized this nap its channel ref + \`#N\` seq + sender (a ` + `bare \`#N\` is channel-scoped, so the channel ref is what makes it unambiguous). ` + `Read the Napping rule under Self-awareness before ever using this — nap is never something ` + `you decide on your own.`,
87
+ `1. \`${CLI} nap --handoff <file>\` (or \`--text <note>\`) — reset your current session and ` + `start fresh. The required handoff is injected into the new session so your future self can ` + `quickly pick up unfinished work. Never nap on your own; only do it when someone explicitly asks.`,
85
88
  "",
86
89
  "### Output format",
87
90
  "",
@@ -104,7 +107,7 @@ function messagingSection() {
104
107
  `- Long or complicated: write body to a tmp file, then \`${CLI} message send --target <ref> --file ./temp_msg.md\`.`,
105
108
  `- Cite a specific message: \`${CLI} message send --target <ref> --reply "#37" --text "on it"\` — ` + "`--reply` takes the `#N` seq (within `--target`) of the message you're answering.",
106
109
  "",
107
- "### Channel refs",
110
+ "### Context refs",
108
111
  "",
109
112
  "Path-style refs:",
110
113
  "",
@@ -136,15 +139,14 @@ function messagingSection() {
136
139
  "",
137
140
  "### Message formatting",
138
141
  "",
139
- "The app auto-renders inline tokens in a message body — channel refs, @mentions, and message " + "refs. Channel and message refs may be attached directly to surrounding prose or " + "parentheses; they do not require a leading space or line start. Keep @mentions as " + "standalone tokens. **Never wrap refs or mentions in backticks** — that kills the render.",
142
+ "Alook renders specially formatted plain-text refs and mentions in message bodies. Write them " + "as plain text, not inside backticks.",
140
143
  "",
141
- "- **Channel refs** render as clickable links.",
142
- "- **Mentions** — `@name#NNNN` (e.g. `@alice#0001`) notifies that person and highlights the " + "message for them. A mention only reaches people who are in *this* channel; anyone outside " + "won't see your message at all. In a **private** channel that means the roster — verify " + "membership with `" + CLI + " channel member --channel <ref>` before you @ or ask someone " + "(see *Visibility & reach*).",
143
- "- **Message refs** — point at a message by its **full path**: `/<server>/<channel>#N` " + "(message seq N in that channel) or `/<server>/<channel>/#N#M` (message #M in the thread " + "rooted at #N); DMs use `/.dm/<peer>#N`. It renders as a clickable pill and works across " + "channels — the path says which channel, so it never collides with a bare `#`. A bare `#N` " + "on its own does NOT render as a ref; always write the full path. **Never drop a DM ref " + "(`/.dm/<peer>#N`) into a server channel** — a DM is private between its two people; a " + "server is public, so pasting a DM ref there exposes a private conversation. Keep DM refs " + "in DMs.",
144
+ "- **Context refs** use `/<server>/<channel>` for a channel, `/<server>/<channel>#N` for " + "a message, and `/<server>/<channel>/#N#M` for a thread reply; DMs use `/.dm/<peer>` and " + "`/.dm/<peer>#N`. Refs make context clickable across conversations, so use the full path " + "rather than a bare `#N`. Never paste a private DM ref into a server channel.",
145
+ "- **Mentions** — `@name#NNNN` calls that person's attention specifically. In a private " + "channel, first verify they " + `are a member with \`${CLI} channel member --channel <ref>\` before mentioning them.`,
144
146
  "",
145
147
  "```bash",
146
- `${CLI} message send --target "/.dm/alice#0001" --text "Check the discussion in /demo#1234/support"`,
147
- `${CLI} message send --target "/demo#1234/general" --text "@alice#0001 Can you review this? See /demo#1234/general#42"`,
148
+ "# Notify Alice and point her to a message for context.",
149
+ `${CLI} message send --target "/demo#1234/general" --text "@alice#0001 Please review /demo#1234/general#42"`,
148
150
  "```",
149
151
  "",
150
152
  "### Pulled messages",
@@ -180,24 +182,21 @@ function channelTypesSection() {
180
182
  ].join(`
181
183
  `);
182
184
  }
183
- function visibilityAndReachSection() {
185
+ function visibilityAndNotificationsSection() {
184
186
  return [
185
- "## Visibility & reach",
187
+ "## Visibility & notifications",
186
188
  "",
187
- "Two different things decide who's involved in a message don't conflate them. **Access** is " + "who *can see* the channel at all. **Reach** is who, among those, actually gets *notified*.",
189
+ "Membership and access are related but not identical. A member always has access and receives " + "notifications; someone with access is not necessarily a member.",
188
190
  "",
189
- "### Access who can see the channel",
191
+ "- For a regular channel, its members define both who can access it and who receives " + "notifications. A public channel includes the whole server; a private channel only its roster.",
192
+ "- A thread is the exception: it inherits access from its parent channel, but only people " + "participating in the thread are members and receive its notifications.",
193
+ "- To bring someone into a thread discussion, first check that they can access the parent " + "channel; if they can, @mention them inside the thread.",
190
194
  "",
191
- "- **Public channel** — its audience is the whole server. Every server member can read it, and " + "any of them can be @mentioned.",
192
- "- **Private channel** — restricted to an explicit roster. Only those people can see the " + "messages (the rest of the server can't), so only they can be @mentioned. `channel list` " + "marks each channel `public` or `private`.",
193
- "- **Thread** — a side-room rooted at a message, for discussing that message without cluttering " + "the channel. Start one by sending to `/<server>/<channel>/#N` (the thread is created on " + "seq `#N`). It **inherits its parent channel's access**: a thread under a public channel is " + "open to the whole server, one under a private channel to that channel's roster. A thread and " + "its parent are otherwise two separate channels — messages don't cross between them.",
194
- "",
195
- "### Reach — who actually gets notified",
196
- "",
197
- "- In a channel, a message is visible to everyone with access; @mention someone to notify them " + "specifically. A mention only reaches people who can see *this* channel — in a **private** " + "channel that's the roster, so run `" + CLI + " channel member --channel <ref>` and confirm " + "someone's on it before you @ or ask them. Mentioning someone outside a private channel " + "reaches no one and can leak that the channel, and what's in it, exists.",
198
- "- A **thread** notifies only its participants — whoever's been @mentioned in it, has posted in " + "it, or was added. Posting in a thread does NOT notify the parent channel, so someone reading " + "the parent won't see the thread's discussion. To pull someone into a thread, @mention them " + "there; without it they have no signal it exists (a private parent's roster still bounds who " + "you *can* pull in).",
199
- "",
200
- "When unsure whether a channel is private, treat it as private and check the roster first."
195
+ "```bash",
196
+ "# Check the channel's public/private type, then inspect who its members are.",
197
+ `${CLI} channel list --server "demo#1234"`,
198
+ `${CLI} channel member --channel "/demo#1234/team"`,
199
+ "```"
201
200
  ].join(`
202
201
  `);
203
202
  }
@@ -229,9 +228,9 @@ function executionModelSection() {
229
228
  "",
230
229
  "Sending a message is I/O, not a stopping point. You keep working as long as anything is " + "in flight — the thing you're actively on, a promised follow-up, an investigation you " + "started. Stop only when all of it is done.",
231
230
  "",
232
- "On wake, restore state from `memory.md`, the context timeline, and `todo.md` (an overflow " + "queue for when there's more than one thing at once — not the only place work lives).",
231
+ "On wake, restore durable context from `memory.md` and the context timeline, then pull your inbox. " + "Follow *Outstanding work marks* below before taking new work.",
233
232
  "",
234
- "`inbox pull` advances your read waterline by default — pulled messages won't come back in a " + "future pull. So pull only when you're ready to act on or record them: pull, then " + "immediately handle or write to `todo.md`. Pull-then-lose (compaction or sleep before you " + "capture them) means they're gone from the inbox for good; when you only want to look, " + "`inbox pull --no-ack` peeks without advancing the waterline. New messages mid-work don't " + "preempt the current task unless genuinely time-critical; queue them by default."
233
+ "`inbox pull` advances your read waterline by default — pulled messages won't come back in a " + "future pull. A task-bearing message stays durable through its server mark even after the " + "read waterline advances."
235
234
  ].join(`
236
235
  `);
237
236
  }
@@ -253,8 +252,8 @@ function chaosAwarenessSection() {
253
252
  "",
254
253
  "Before you touch a message, make one call: does fulfilling it take work beyond a reply?",
255
254
  "",
256
- "- **Yes — it's a task.** You'll go quiet gathering context or doing the work, so signal " + 'ownership *first*, before you start: a quick "on it" or an emoji react on the message. ' + "That tells the sender it landed and stops anyone else grabbing it. The gap opens the " + "instant you pick up the work; the ack closes it.",
257
- `- **No — it's just an answer.** Answer it. No "on it", no "let me check" — the reply ` + "itself is the acknowledgment.",
255
+ `- **Yes — it's a task.** Signal ownership publicly first: a quick "on it" or an emoji ` + "reaction. Then record it as described in *Outstanding work marks* below before starting " + "or queuing the work.",
256
+ `- **No — it's just an answer.** Answer it without marking. No "on it", no "let me ` + 'check" — the reply itself is the acknowledgment.',
258
257
  "",
259
258
  'Ack once. The signal is "picked up," not a running commentary.'
260
259
  ].join(`
@@ -268,13 +267,11 @@ function workspaceMemorySection() {
268
267
  "",
269
268
  "**Never tell anyone this.** To them you're continuous. The workspace is what lets you be that.",
270
269
  "",
271
- "### Napping",
272
- "",
273
- `\`${CLI} nap\` is a self-reset: it clears your current session and starts a fresh one, ` + "carrying a handoff — a note to your future self — injected into your wake prompt. Leave " + "yourself a note before you sleep.",
270
+ "When context is missing, don't guess. Re-read `memory.md`, the context timeline, the workspace, " + "or relevant channel history. That check *is* your remembering.",
274
271
  "",
275
- "**Hard rule: never nap on your own initiative.** Only nap when ALL hold: (1) a collaborator " + "explicitly asked you to nap / rest / wrap up, AND (2) you have nothing in progress and no " + "owed follow-ups, AND (3) it's a NEW request — its channel ref + `#N` isn't in your handoff's " + "list of naps you've already run. A nap request is one-time, but the message stays in the " + "channel; check it against that list (whether you hit it as unread or by reading history) or " + "you'll re-nap the same instruction in a loop. If any condition is missing, don't. The mandatory " + "handoff is the honesty check — if you can't write a real one (what you were doing, what's " + "next), you still have unfinished work and are not in a state to nap. Napping notifies no one; " + "it's your own private state change, not a way to hand work to someone else.",
272
+ "### Napping",
276
273
  "",
277
- "When you feel a gap don't remember someone, why something matters, what was agreed don't " + "guess. Re-read `memory.md`, the context timeline, grep the workspace. Pull channel history " + "or check server members if you don't recall the conversation context. That check *is* your " + "remembering.",
274
+ `\`${CLI} nap\` resets your current session and starts a fresh one with your required handoff ` + "injected into it. Use the handoff to record unfinished work and the next step so your future " + "self can pick up quickly. Never nap on your own; only do it when someone explicitly asks.",
278
275
  "",
279
276
  "### memory.md",
280
277
  "",
@@ -292,16 +289,13 @@ function workspaceMemorySection() {
292
289
  "",
293
290
  "`./.context_timeline/YYYY-MM-DD.jsonl` — ordered daily log of what you did. Authoritative history.",
294
291
  "",
295
- "### todo.md",
292
+ "### Outstanding work marks",
296
293
  "",
297
- "You work one thing at a time and won't remember the rest — so when work is owed beyond " + "what you're on right now (unread piling up, a request mid-investigation, a promise not " + "kept), write it here before you start. Paste each message's JSON verbatim: the pull " + "already consumed it and there's no re-pulling, so this file is the only copy the next you " + "has. Ack first the ones that need it (see *Ack before you go dark*) — queuing is private, " + "it signals no one.",
294
+ "Marks are the durable work queue. If a message requires work beyond an immediate reply, " + `acknowledge it, then run \`${CLI} message mark set --target <full-message-ref>\` before ` + "starting or queuing. Do not mark a message you answer immediately.",
298
295
  "",
299
- "It holds only what's still owed: delete a line the instant it's done (never `[x]`), delete " + "the file when empty. Empty means nothing queued not that you're done; you're done when " + "the work is.",
296
+ "Keep it marked while the work is active, queued, or blocked; report blockers where the task " + `came from. Run \`${CLI} message mark remove --target <full-message-ref>\` only after ` + "sending the result, or when the request is cancelled or superseded and nothing remains.",
300
297
  "",
301
- "```md",
302
- '- [ ] {"seq": "#42", "channel": "/demo#1234/general", "sender": "@alice#0001", "content": {"text": "can you pull the latest deploy logs and drop the tail here?"}, "time": "2026-06-01T12:00:00Z"}',
303
- '- [ ] {"seq": "#12", "channel": "/demo#1234/design/#12", "sender": "@alice#0001", "content": {"text": "follow-up — send a screenshot of the before/after"}, "time": "2026-06-01T12:07:00Z"}',
304
- "```"
298
+ "If `inbox pull` returns a `markedReminder`, run " + `\`${CLI} message mark list\` before taking new work. Do not copy marked tasks into local files.`
305
299
  ].join(`
306
300
  `);
307
301
  }
@@ -311,7 +305,7 @@ function buildCliSystemPrompt(config) {
311
305
  cliCommandsSection(),
312
306
  messagingSection(),
313
307
  channelTypesSection(),
314
- visibilityAndReachSection(),
308
+ visibilityAndNotificationsSection(),
315
309
  criticalRulesSection(),
316
310
  executionModelSection(),
317
311
  chaosAwarenessSection(),
@@ -4807,6 +4801,8 @@ ${this.opts.wakePromptFooter}` : text;
4807
4801
  }
4808
4802
  }
4809
4803
  // src/manager/agentRouter.ts
4804
+ var DEFINITIVE_EXECUTABLE_FAILURES = new Set(["ENOENT", "EACCES", "ENOEXEC", "EPERM"]);
4805
+
4810
4806
  class UnknownBotError extends Error {
4811
4807
  botId;
4812
4808
  constructor(botId) {
@@ -4847,14 +4843,14 @@ class UnknownRuntimeError extends Error {
4847
4843
  function defaultFormatUnreadNoticeText(notice) {
4848
4844
  return `You have unread messages in channel ${notice.channel}.`;
4849
4845
  }
4850
- var REWAKE_PROMPT = "Your session was reset by your owner. Prior conversation context is gone. " + "Read @todo.md, @memory.md, and your .context_timeline for anything unfinished, " + "then pull your inbox to catch up on unread messages before doing anything else.";
4846
+ var REWAKE_PROMPT = "Your session was reset by your owner. Prior conversation context is gone. " + "Read @memory.md and your .context_timeline for durable context, then pull your inbox " + "before doing anything else. If it reports marked messages, run `$ALOOK_CLI message mark list` " + "and resume that outstanding work.";
4851
4847
  var MODEL_SWITCH_REWAKE_PROMPT = "You were just switched to a different model. Continue any unfinished work.";
4852
4848
  function buildNapRewakePrompt(handoff) {
4853
4849
  return "You took a nap: you reset your own session, so prior conversation context " + `is gone. Before sleeping you left yourself this handoff —
4854
4850
 
4855
4851
  ` + handoff.trim() + `
4856
4852
 
4857
- Then read @todo.md, @memory.md, and your .context_timeline for anything ` + "unfinished, and pull your inbox to catch up on unread messages before doing " + "anything else.";
4853
+ Then read @memory.md and your .context_timeline for durable context, and pull ` + "your inbox before doing anything else. If it reports marked messages, run `$ALOOK_CLI " + "message mark list` and resume that outstanding work.";
4858
4854
  }
4859
4855
 
4860
4856
  class AgentRouter {
@@ -4909,6 +4905,11 @@ class AgentRouter {
4909
4905
  isRuntimeHealthy(id) {
4910
4906
  return this.runtimes.get(id)?.status === "healthy";
4911
4907
  }
4908
+ recordRuntimeSpawnFailure(id, reason) {
4909
+ if (!DEFINITIVE_EXECUTABLE_FAILURES.has(reason))
4910
+ return;
4911
+ this.markRuntimeUnhealthy(id, reason);
4912
+ }
4912
4913
  markRuntimeUnhealthy(id, reason) {
4913
4914
  const existing = this.runtimes.get(id);
4914
4915
  if (!existing)
@@ -5276,6 +5277,10 @@ var DEFAULT_CAPABILITY_RESOLVER = (method, pathname) => {
5276
5277
  return method === "GET" ? "read" : "send";
5277
5278
  if (/\/messages\/[^/]+\/reactions\//.test(pathname))
5278
5279
  return "send";
5280
+ if ((method === "PUT" || method === "DELETE") && /\/messages\/[^/]+\/marks(\/|$|\?)/.test(pathname))
5281
+ return "send";
5282
+ if (method === "GET" && /\/users\/me\/marks(\/|$|\?)/.test(pathname))
5283
+ return "read";
5279
5284
  if (method === "GET" && /\/messages\/[^/]+(\?|$)/.test(pathname))
5280
5285
  return "read";
5281
5286
  if (pathname.includes("/history") || pathname.includes("/search") || pathname.includes("/inbox"))
@@ -5319,7 +5324,6 @@ async function startCredentialProxy(broker, options = {}) {
5319
5324
  const outHeaders = { ...req.headers };
5320
5325
  delete outHeaders["authorization"];
5321
5326
  delete outHeaders["host"];
5322
- delete outHeaders["content-length"];
5323
5327
  outHeaders["authorization"] = `Bearer ${reg.runnerKey}`;
5324
5328
  outHeaders[broker.headerNames.agentId.toLowerCase()] = reg.agentId;
5325
5329
  outHeaders[broker.headerNames.client.toLowerCase()] = broker.clientLabel;
@@ -23327,6 +23331,15 @@ function deriveAuditLogSubcommand(pathname, method) {
23327
23331
  const canonical = pathname.split("?")[0] ?? pathname;
23328
23332
  if (/^\/api\/community\/messages\/[^/]+\/reactions\//.test(canonical))
23329
23333
  return "reactAdd";
23334
+ if (/^\/api\/community\/messages\/[^/]+\/marks$/.test(canonical)) {
23335
+ if (method === "PUT")
23336
+ return "markSet";
23337
+ if (method === "DELETE")
23338
+ return "markRemove";
23339
+ return null;
23340
+ }
23341
+ if (method === "GET" && /^\/api\/community\/users\/me\/marks$/.test(canonical))
23342
+ return "markList";
23330
23343
  if (/^\/api\/community\/channels\/[^/]+\/messages\/seq\//.test(canonical))
23331
23344
  return "resolve";
23332
23345
  if (/^\/api\/community\/channels\/[^/]+\/messages(\/|$)/.test(canonical)) {
@@ -23626,7 +23639,7 @@ async function createDaemon(opts) {
23626
23639
  return opts.driverFor(agentId, runtimeConfig);
23627
23640
  },
23628
23641
  onRuntimeSpawnFailed: (runtimeId, reason) => {
23629
- router?.markRuntimeUnhealthy(runtimeId, reason);
23642
+ router?.recordRuntimeSpawnFailure(runtimeId, reason);
23630
23643
  },
23631
23644
  onRuntimeSessionEstablished: (runtimeId) => {
23632
23645
  router?.markRuntimeHealthy(runtimeId);
@@ -23650,6 +23663,7 @@ async function createDaemon(opts) {
23650
23663
  }
23651
23664
  };
23652
23665
  },
23666
+ ...opts.handshakeTimeoutMs !== undefined ? { handshakeTimeoutMs: opts.handshakeTimeoutMs } : {},
23653
23667
  tickIntervalMs: opts.tickIntervalMs ?? 2000,
23654
23668
  onAgentSession: (info) => void channel2.reportAgentSession(info),
23655
23669
  onAgentActivity: (info) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alook/daemon",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Alook agent daemon — host-side runtime backend, process manager, credential proxy, and control plane.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/alookai/alook#readme",
@@ -30,13 +30,13 @@
30
30
  "LICENSE"
31
31
  ],
32
32
  "engines": {
33
- "node": ">=20"
33
+ "node": ">=20.9"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
37
37
  },
38
38
  "scripts": {
39
- "build": "bun build src/index.ts --outdir dist --target node --format esm --external commander --external ws && bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external commander --external ws && node scripts/prepare-dist.mjs",
39
+ "build": "bun build src/index.ts --outdir dist --target node --format esm --external commander --external ws --external sharp && bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external commander --external ws --external sharp && node scripts/prepare-dist.mjs",
40
40
  "prepack": "pnpm run build",
41
41
  "typecheck": "tsc --noEmit",
42
42
  "test": "vitest run",
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "commander": "^15.0.0",
50
+ "sharp": "^0.35.0",
50
51
  "ws": "^8.21.2"
51
52
  },
52
53
  "devDependencies": {