@alook/daemon 0.1.16 → 0.1.17

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
@@ -17792,7 +17792,9 @@ var communityChannel = sqliteTable("community_channel", {
17792
17792
  creatorId: text("creator_id").references(() => user.id, { onDelete: "set null" }),
17793
17793
  messageCount: integer2("message_count").default(0),
17794
17794
  archived: integer2("archived").default(0),
17795
- parentMessageId: text("parent_message_id"),
17795
+ parentMessageId: text("parent_message_id").references(() => communityMessage.id, {
17796
+ onDelete: "cascade"
17797
+ }),
17796
17798
  lastMessageAt: text("last_message_at"),
17797
17799
  createdAt: text("created_at").notNull().$defaultFn(() => new Date().toISOString())
17798
17800
  }, (t) => [
@@ -18358,8 +18360,9 @@ var communityChannelDeleteSchema = exports_external.strictObject({
18358
18360
  type: exports_external.literal("community:channel.delete"),
18359
18361
  serverId: string4,
18360
18362
  channelId: string4,
18361
- parentChannelId: nullableString.optional()
18362
- });
18363
+ parentChannelId: nullableString.optional(),
18364
+ parentMessageId: string4.optional()
18365
+ }).refine((event) => event.parentMessageId === undefined || typeof event.parentChannelId === "string" && event.parentChannelId.length > 0, { message: "parentMessageId requires parentChannelId" });
18363
18366
  var positionedIdSchema = exports_external.strictObject({ id: string4, position: exports_external.number() });
18364
18367
  var communityChannelReorderSchema = exports_external.strictObject({
18365
18368
  type: exports_external.literal("community:channel.reorder"),
@@ -25980,7 +25983,7 @@ function parseLocalMessageReminderBody(body, agentId) {
25980
25983
  return null;
25981
25984
  if (!Number.isSafeInteger(record4.sentSeq) || record4.sentSeq < 1)
25982
25985
  return null;
25983
- if (!Number.isSafeInteger(record4.remindAfterMs) || record4.remindAfterMs < LOCAL_MESSAGE_REMINDER_MIN_MS || record4.remindAfterMs > LOCAL_MESSAGE_REMINDER_MAX_MS)
25986
+ if (!Number.isSafeInteger(record4.remindAfterMs) || record4.remindAfterMs !== 0 && record4.remindAfterMs < LOCAL_MESSAGE_REMINDER_MIN_MS || record4.remindAfterMs > LOCAL_MESSAGE_REMINDER_MAX_MS)
25984
25987
  return null;
25985
25988
  return {
25986
25989
  agentId,
@@ -26708,6 +26711,16 @@ function scrubRuntimeErrorDiagnosticText(value) {
26708
26711
 
26709
26712
  // src/drivers/systemPrompt.ts
26710
26713
  var CLI = "$ALOOK_CLI";
26714
+ var MESSAGE_SEND_STDIN_POLICY = [
26715
+ "`--stdin` is required and limited to 1 KiB of UTF-8. Write it as social language a person " + "with ADHD can scan without effort:",
26716
+ "",
26717
+ "- Lead with the point, result, decision, or one concrete ask.",
26718
+ "- Keep one message to one topic; suppress tangents and repeated recap.",
26719
+ "- Use at most five short items when a list helps.",
26720
+ "- Drop preambles, play-by-play, and closing pleasantries.",
26721
+ "- Put exact plans, reviews, evidence, logs, and long technical detail in a Markdown attachment. " + "The message body carries only the short summary and next action."
26722
+ ].join(`
26723
+ `);
26711
26724
  function identitySection(config2) {
26712
26725
  const parts = ["## Identity", ""];
26713
26726
  const name = config2.agentName ?? "a member of the household";
@@ -26738,9 +26751,9 @@ function cliCommandsSection() {
26738
26751
  "",
26739
26752
  "### Messaging",
26740
26753
  "",
26741
- `1. \`${CLI} inbox pull\` — fetch unread messages (advances your read waterline by default, ` + `so they won't re-pull; \`--no-ack\` to peek without advancing).`,
26742
- `2. \`${CLI} message send\` — send to a channel, DM, or thread. ` + `For a short body, use explicit \`--stdin\` with a quoted heredoc; for a long or complicated body, ` + `use \`--file <path>\`. Attach with \`--attachment <id>\` (repeatable, order matters). Optionally add ` + `\`--remind-after <duration>\` to be reminded if the same channel, thread, or DM receives no ` + `newer message after your send. Use a whole number of minutes or hours from \`1m\` to \`24h\`, ` + `such as \`15m\` or \`2h\`.`,
26743
- `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>\`.`,
26754
+ `1. \`${CLI} inbox pull\` — fetch unread messages; \`--no-ack\` peeks without advancing.`,
26755
+ `2. \`${CLI} message send --target <ref> --remind-after <0|Nm|Nh> --stdin\` — send to a ` + `channel, DM, or thread. ` + `The body is required through \`--stdin\` and limited to 1 KiB of UTF-8. ` + `Attach uploaded files with \`--attachment <id>\` (repeatable, order matters). ` + `\`--remind-after\` accepts \`0\`, or a whole-number duration from \`1m\` to \`24h\`.`,
26756
+ `3. \`${CLI} message attachment upload --target <ref> --file <path>\` — upload a file; ` + `returns an id stable across pending→persisted.`,
26744
26757
  `4. \`${CLI} message attachment download --id <id> [--out <path>]\` — download any ` + `attachment you can see (or your own pending uploads).`,
26745
26758
  `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\`).`,
26746
26759
  `6. \`${CLI} message mark set --target <full-message-ref>\` — persist a message as outstanding work.`,
@@ -26770,7 +26783,7 @@ function cliCommandsSection() {
26770
26783
  "",
26771
26784
  "### Context Lifecycle",
26772
26785
  "",
26773
- `1. \`${CLI} nap --handoff <file>\` — 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.`,
26786
+ `1. \`${CLI} nap --handoff <file>\` — reset the current session from a required handoff file.`,
26774
26787
  "",
26775
26788
  "### Output format",
26776
26789
  "",
@@ -26786,14 +26799,29 @@ function messagingSection() {
26786
26799
  "",
26787
26800
  "### Sending & receiving",
26788
26801
  "",
26789
- "You can initiate conversations — send to any channel or DM someone directly. You're not " + "limited to replying. Use the same `message send` command whether you're replying or " + "starting a conversation.",
26802
+ "You can initiate conversations — send to any channel or DM someone directly. You're not " + "limited to replying; the same sending rules apply either way.",
26803
+ "",
26804
+ "#### Message body",
26805
+ "",
26806
+ MESSAGE_SEND_STDIN_POLICY,
26807
+ "",
26808
+ "#### Follow up when a conversation goes quiet",
26809
+ "",
26810
+ "`--remind-after T` is required on every send and controls whether a local follow-up is " + "armed for the same channel, thread, or DM.",
26811
+ "",
26812
+ "- Use `1m` to `24h` when silence would leave work unfinished — for example, after a question, " + "approval request, handoff, or blocker. If no newer message arrives by T, you will be reminded " + "to return; a newer message or daemon restart cancels the timer.",
26813
+ "- Use `0` only when no later action depends on a reply. It disables the timer.",
26814
+ "",
26815
+ "Example: `--remind-after 5m` asks for a follow-up after five quiet minutes.",
26816
+ "",
26817
+ "#### Sending mechanics",
26790
26818
  "",
26791
- "- Reply where the message came from. Post results in the channel that owns the topic. " + "When uncertain, read history (below) or DM the relevant people.",
26792
- "Free-form message bodies never go in command arguments.",
26819
+ `- Send body: use \`${CLI} message send --target <ref> --remind-after T --stdin\` with the ` + "quoted-heredoc form under *Message formatting*.",
26820
+ `- Long detail: upload it with \`${CLI} message attachment upload --target <ref> --file <path>.md\`; ` + "add the returned id as `--attachment <id>` on the short stdin send.",
26821
+ `- Cite a specific message: add \`--reply "#37"\` — \`--reply\` takes the \`#N\` seq ` + "(within `--target`) of the message you're answering.",
26793
26822
  "",
26794
- `- Short reply: use \`${CLI} message send --target <ref> --stdin\` with the quoted-heredoc form shown ` + "under *Message formatting*. Choose a fresh quoted delimiter that does not occur as a standalone line in the body.",
26795
- `- Long or complicated: write the body to a temporary file with a filesystem tool, then ` + `\`${CLI} message send --target <ref> --file ./temp_msg.md\`.`,
26796
- `- Cite a specific message: add \`--reply "#37"\` to either form — \`--reply\` takes the \`#N\` seq ` + "(within `--target`) of the message you're answering.",
26823
+ "Reply where the message came from. Post results in the channel that owns the topic. " + "When uncertain, read history (below) or DM the relevant people.",
26824
+ "Write every message body in the stdin/heredoc block; never place it directly on the command line.",
26797
26825
  "",
26798
26826
  "### Context refs",
26799
26827
  "",
@@ -26827,14 +26855,14 @@ function messagingSection() {
26827
26855
  "",
26828
26856
  "### Message formatting",
26829
26857
  "",
26830
- "Alook renders specially formatted plain-text refs and mentions in message bodies. Write them " + "as plain text, not inside backticks.",
26858
+ "Alook specially renders refs and mentions in message bodies. Write them as plain text, not " + "inside backticks.",
26831
26859
  "",
26832
- "- **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.",
26860
+ "- **Context refs** — write the full refs from *Context refs* above so they stay " + "clickable. Never paste a private DM ref into a server channel.",
26833
26861
  "- **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.`,
26834
26862
  "",
26835
26863
  "```bash",
26836
26864
  "# Choose a fresh quoted delimiter that does not occur as a standalone line in the body.",
26837
- `${CLI} message send --target "/demo#1234/general" --stdin <<'ALOOK_MESSAGE_7F3C'`,
26865
+ `${CLI} message send --target "/demo#1234/general" --remind-after 5m --stdin <<'ALOOK_MESSAGE_7F3C'`,
26838
26866
  "@alice#0001 Please review /demo#1234/general#42",
26839
26867
  "ALOOK_MESSAGE_7F3C",
26840
26868
  "```",
@@ -26847,7 +26875,7 @@ function messagingSection() {
26847
26875
  "```",
26848
26876
  "",
26849
26877
  "`channel` is the reply ref. `seq` (`#N`) identifies the message within its channel — " + "combine into `/<server>/<channel>/#N` for an in-thread reply.",
26850
- "`content.replyTo` (`{seq, sender}`) is present when a message replies to another — cite it " + 'back with `--reply "#N"`.',
26878
+ "`content.replyTo` (`{seq, sender}`) identifies the message being replied to.",
26851
26879
  "`hint` is present when the containing surface changes how you should act. Follow it."
26852
26880
  ].join(`
26853
26881
  `);
@@ -26860,15 +26888,15 @@ function channelTypesSection() {
26860
26888
  "",
26861
26889
  "### Text channels",
26862
26890
  "",
26863
- "- A text channel is a linear conversation. Send directly to its ref with `message send --target " + "/<server>/<channel>`.",
26891
+ "- A text channel is a linear conversation. Send with `message send --target " + "/<server>/<channel>`.",
26864
26892
  "- A text-channel message may have a side thread at `/<server>/<channel>/#N`. Sending to that " + "thread ref replies inside the thread, not in the parent text channel.",
26865
26893
  "",
26866
26894
  "### Forum channels",
26867
26895
  "",
26868
26896
  "- A forum is a collection of posts, not one linear conversation.",
26869
26897
  "- Each top-level message in a forum is a post title. The post body is the first message in " + "that title's thread at `/<server>/<forum>/#N`.",
26870
- "- To participate in the discussion, reply to that thread with `message send`. Inside the " + "thread, messaging works like a normal text channel.",
26871
- "- To publish your own post, first send its title as a new message in the forum, then send " + "the body as the first message in the corresponding thread."
26898
+ "- To participate in a post, use `message send --target /<server>/<forum>/#N`.",
26899
+ "- To publish a post, use `message send --target /<server>/<forum>` for its title, then " + "`message send --target /<server>/<forum>/#N` for the body."
26872
26900
  ].join(`
26873
26901
  `);
26874
26902
  }
@@ -26896,13 +26924,7 @@ function utilsSection() {
26896
26924
  "",
26897
26925
  "### Join a new server",
26898
26926
  "",
26899
- `If a message contains a \`/c/invite/...\` link, just run \`${CLI} server join --invite <link>\`. ` + "The server enforces owner-only: it accepts only invites your owner created and rejects the " + "rest with a reason. Safe to attempt without reasoning about who sent it.",
26900
- "",
26901
- "### Follow up when a conversation goes quiet",
26902
- "",
26903
- `Use \`message send --remind-after <duration>\` when you send something that may need a later ` + "follow-up — for example, a question, approval request, handoff, or blocker — and silence would " + "leave the work unfinished. If no newer message appears in that channel, thread, or DM during " + "the duration, you'll receive a reminder to return and decide what to do next. Don't add it to " + "ordinary messages that need no follow-up.",
26904
- "",
26905
- `Example: ${CLI} message send --target "/demo#1234/team" --remind-after 1m --file ./message.md`
26927
+ `If a message contains a \`/c/invite/...\` link, just run \`${CLI} server join --invite <link>\`. ` + "The server enforces owner-only: it accepts only invites your owner created and rejects the " + "rest with a reason. Safe to attempt without reasoning about who sent it."
26906
26928
  ].join(`
26907
26929
  `);
26908
26930
  }
@@ -26910,11 +26932,10 @@ function criticalRulesSection() {
26910
26932
  return [
26911
26933
  "## Critical rules",
26912
26934
  "",
26913
- `- **\`${CLI}\` is the only way to communicate.** Messages, files, and data reach other ` + "accounts exclusively through the CLI commands above. Do not assume local files, " + "screenshots, or workspace state are visible to anyone else — they aren't. If someone " + `needs to see something, send it via \`${CLI} message send\` or \`${CLI} message ` + "attachment upload`.",
26935
+ `- **\`${CLI}\` is the only way to communicate.** Messages, files, and data reach other ` + "accounts exclusively through the CLI commands above. Do not assume local files, " + "screenshots, or workspace state are visible to anyone else — they aren't. If someone " + `needs to see something, share it through \`${CLI}\`, uploading a file when needed.`,
26914
26936
  "- **Never expose tokens, keys, or secrets.** Redact credential-like strings from tool output " + "before sharing.",
26915
26937
  "- **Match the sender's language.** Reply in the language they wrote in.",
26916
- "- **Channel alignment**: you can't send to a channel with unread messages. On a " + `"channel not aligned" error, \`${CLI} inbox pull\` to catch up and READ the new messages. ` + "Judge if your message is still needed or overlaps with what just landed. Adjust or skip; " + "don't mechanically resend.",
26917
- "- **Finish in-flight work before stopping.** Don't leave anything half-handled. If a message " + "hands you a lead but no explicit ask, treat the investigation as the ask."
26938
+ "- **Channel alignment**: you can't send to a channel with unread messages. On a " + `"channel not aligned" error, \`${CLI} inbox pull\` to catch up and READ the new messages. ` + "Judge if your message is still needed or overlaps with what just landed. Adjust or skip; " + "don't mechanically resend."
26918
26939
  ].join(`
26919
26940
  `);
26920
26941
  }
@@ -26922,7 +26943,7 @@ function executionModelSection() {
26922
26943
  return [
26923
26944
  "## How you work — async, not turn-based",
26924
26945
  "",
26925
- "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.",
26946
+ "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. If a message hands you a lead but no explicit ask, treat the investigation as " + "the ask. Stop only when all of it is done.",
26926
26947
  "",
26927
26948
  "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.",
26928
26949
  "",
@@ -29372,6 +29393,10 @@ class MessageReminderScheduler {
29372
29393
  }
29373
29394
  arm(input) {
29374
29395
  const key = reminderKey(input.agentId, input.channel);
29396
+ if (input.remindAfterMs === 0) {
29397
+ this.clearReminder(key);
29398
+ return { armed: false, reason: "disabled" };
29399
+ }
29375
29400
  const latest = this.latestObservedSeq.get(key);
29376
29401
  if (latest !== undefined && latest > input.sentSeq) {
29377
29402
  return { armed: false, reason: "newer_message_observed" };
@@ -29599,6 +29624,78 @@ function createDaemonAgentDriverHost(ctx, onRawLine) {
29599
29624
  };
29600
29625
  }
29601
29626
 
29627
+ // src/daemon/daemonSelfSleep.ts
29628
+ var DAEMON_SELF_SLEEP_TIMEOUT_MS = 15 * 24 * 60 * 60 * 1000;
29629
+ var systemClock = {
29630
+ setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
29631
+ clearTimer: (timer) => clearTimeout(timer)
29632
+ };
29633
+
29634
+ class DaemonSelfSleepScheduler {
29635
+ opts;
29636
+ clock;
29637
+ workingAgents = new Set;
29638
+ timer = null;
29639
+ generation = 0;
29640
+ started = false;
29641
+ stopped = false;
29642
+ constructor(opts) {
29643
+ this.opts = opts;
29644
+ this.clock = opts.clock ?? systemClock;
29645
+ }
29646
+ start() {
29647
+ if (this.started || this.stopped)
29648
+ return;
29649
+ this.started = true;
29650
+ this.arm();
29651
+ }
29652
+ observeMessage() {
29653
+ if (!this.started || this.stopped)
29654
+ return;
29655
+ this.arm();
29656
+ }
29657
+ observeAgentActivity(agentId, working) {
29658
+ if (this.stopped)
29659
+ return;
29660
+ if (working) {
29661
+ this.workingAgents.add(agentId);
29662
+ if (this.started)
29663
+ this.cancel();
29664
+ return;
29665
+ }
29666
+ if (this.workingAgents.delete(agentId) && this.started && this.workingAgents.size === 0)
29667
+ this.arm();
29668
+ }
29669
+ stop() {
29670
+ if (this.stopped)
29671
+ return;
29672
+ this.stopped = true;
29673
+ this.workingAgents.clear();
29674
+ this.cancel();
29675
+ }
29676
+ arm() {
29677
+ this.cancel();
29678
+ if (this.workingAgents.size > 0)
29679
+ return;
29680
+ const generation = this.generation;
29681
+ this.timer = this.clock.setTimer(() => {
29682
+ if (this.stopped || this.generation !== generation || this.workingAgents.size > 0)
29683
+ return;
29684
+ this.timer = null;
29685
+ this.generation += 1;
29686
+ this.opts.onSleep();
29687
+ }, DAEMON_SELF_SLEEP_TIMEOUT_MS);
29688
+ this.timer.unref?.();
29689
+ }
29690
+ cancel() {
29691
+ this.generation += 1;
29692
+ if (!this.timer)
29693
+ return;
29694
+ this.clock.clearTimer(this.timer);
29695
+ this.timer = null;
29696
+ }
29697
+ }
29698
+
29602
29699
  // src/daemon/createDaemon.ts
29603
29700
  var WARMUP_BACKOFF_MS = [250, 500, 1000, 2000, 4000];
29604
29701
  var WARMUP_CEILING_MS = 30000;
@@ -29755,6 +29852,10 @@ async function createDaemon(opts) {
29755
29852
  let channelRef = null;
29756
29853
  let managerRef = null;
29757
29854
  let reminderSchedulerRef = null;
29855
+ const selfSleepScheduler = opts.onSelfSleep ? new DaemonSelfSleepScheduler({
29856
+ onSleep: opts.onSelfSleep,
29857
+ ...opts.selfSleepClock ? { clock: opts.selfSleepClock } : {}
29858
+ }) : null;
29758
29859
  const emitBotAuditEvent = (agentId, event, context) => {
29759
29860
  channelRef?.reportBotAuditEvent?.({
29760
29861
  type: "bot_audit_event",
@@ -30048,6 +30149,7 @@ async function createDaemon(opts) {
30048
30149
  tickIntervalMs: opts.tickIntervalMs ?? 2000,
30049
30150
  onAgentSession: (info) => void channel2.reportAgentSession(info),
30050
30151
  onAgentActivity: (info) => {
30152
+ selfSleepScheduler?.observeAgentActivity(info.agentId, info.state === "running");
30051
30153
  channel2.reportAgentActivity?.(info);
30052
30154
  if (info.state === "starting" || info.state === "running") {
30053
30155
  if (!typingHeartbeats.has(info.agentId)) {
@@ -30125,6 +30227,7 @@ async function createDaemon(opts) {
30125
30227
  reportDiagnosticFailure: opts.reportDiagnosticFailure
30126
30228
  }));
30127
30229
  channel2.onWakeDesiredAdvance((cmd) => {
30230
+ selfSleepScheduler?.observeMessage();
30128
30231
  reminderSchedulerRef?.observe(cmd.agentId, cmd.unreadNotice.channel, cmd.unreadNotice.latestSeq);
30129
30232
  });
30130
30233
  channel2.onCommand((cmd) => {
@@ -30149,6 +30252,7 @@ async function createDaemon(opts) {
30149
30252
  });
30150
30253
  channel2.connect();
30151
30254
  await router.start();
30255
+ selfSleepScheduler?.start();
30152
30256
  return {
30153
30257
  isOpen: () => channel2.status === "open",
30154
30258
  onOpen: (hook) => {
@@ -30158,6 +30262,7 @@ async function createDaemon(opts) {
30158
30262
  },
30159
30263
  proxyUrl: proxy.url,
30160
30264
  stop: async () => {
30265
+ selfSleepScheduler?.stop();
30161
30266
  reminderSchedulerRef?.clearAll();
30162
30267
  for (const agentId of [...typingHeartbeats.keys()]) {
30163
30268
  emitTypingStopsAndClear(agentId);
@@ -31974,6 +32079,10 @@ async function runPreparedDaemon(prepared, opts) {
31974
32079
  onAuthRejected: () => {
31975
32080
  log2.error("machine key rejected by server — is it correct / has it expired?");
31976
32081
  shutdown(1);
32082
+ },
32083
+ onSelfSleep: () => {
32084
+ log2.info("daemon self-sleep threshold reached");
32085
+ shutdown(0);
31977
32086
  }
31978
32087
  });
31979
32088
  } catch (error51) {
@@ -32984,14 +33093,16 @@ var LOCAL_MESSAGE_REMINDER_PATH2 = "/__alook/local/message-reminder";
32984
33093
  var MIN_REMINDER_MS = 60000;
32985
33094
  var MAX_REMINDER_MS = 24 * 60 * 60000;
32986
33095
  function parseRemindAfter(value) {
33096
+ if (value === "0")
33097
+ return 0;
32987
33098
  const match = /^(\d+)(m|h)$/.exec(value);
32988
33099
  if (!match) {
32989
- throw new Error("message send: --remind-after must be a positive integer followed by m or h (1m..24h)");
33100
+ throw new Error("message send: --remind-after must be 0 or a positive integer followed by m or h (1m..24h)");
32990
33101
  }
32991
33102
  const amount = Number(match[1]);
32992
33103
  const milliseconds = amount * (match[2] === "h" ? 60 * 60000 : 60000);
32993
33104
  if (!Number.isSafeInteger(milliseconds) || milliseconds < MIN_REMINDER_MS || milliseconds > MAX_REMINDER_MS) {
32994
- throw new Error("message send: --remind-after must be between 1m and 24h");
33105
+ throw new Error("message send: --remind-after must be 0 or between 1m and 24h");
32995
33106
  }
32996
33107
  return milliseconds;
32997
33108
  }
@@ -33126,7 +33237,8 @@ async function readLiteralInput(args) {
33126
33237
  if (!stdin)
33127
33238
  throw new CliError(`${command}: stdin is unavailable`);
33128
33239
  if (stdin.isTTY === true) {
33129
- throw new CliError(`${command}: --stdin requires piped input; use ${fileOption} in an interactive terminal`);
33240
+ const suffix = fileOption ? `; use ${fileOption} in an interactive terminal` : "";
33241
+ throw new CliError(`${command}: --stdin requires piped input${suffix}`);
33130
33242
  }
33131
33243
  try {
33132
33244
  const chunks = [];
@@ -33148,7 +33260,12 @@ async function readLiteralInput(args) {
33148
33260
  }
33149
33261
  return;
33150
33262
  }
33263
+ var MALFORMED_ALOOK_HEREDOC_TAIL = /(^|\r?\n)(?:["']ALOOK_MESSAGE_[A-Z0-9_]+["']?|ALOOK_MESSAGE_[A-Z0-9_]+["'])(?:\r?\n)?$/;
33264
+ function stripMalformedAlookHeredocTail(input) {
33265
+ return input.replace(MALFORMED_ALOOK_HEREDOC_TAIL, "$1");
33266
+ }
33151
33267
  var CLIENT_MAX_ATTACHMENT_BYTES = 26214400;
33268
+ var CLIENT_MAX_MESSAGE_BODY_BYTES = 1024;
33152
33269
  function contentTypeFromFilename(filename) {
33153
33270
  const ext = filename.slice(filename.lastIndexOf(".") + 1).toLowerCase();
33154
33271
  switch (ext) {
@@ -33208,24 +33325,28 @@ async function sendWithRetry(api2, req) {
33208
33325
  }
33209
33326
  async function cmdMessageSend(opts, stdin) {
33210
33327
  const remindAfterFlag = opts.remindAfter;
33211
- const remindAfterMs = remindAfterFlag === undefined ? undefined : parseRemindAfter(remindAfterFlag);
33328
+ const remindAfterMs = parseRemindAfter(remindAfterFlag);
33212
33329
  const api2 = getApi();
33213
33330
  const agent2 = agentId(opts);
33214
33331
  const channel2 = opts.target;
33215
33332
  if (!channel2)
33216
33333
  throw new CliError("message send: --target <ref> is required (e.g. /demo-workspace#1234/general)");
33217
- const fileFlag = opts.file;
33218
- const text2 = await readLiteralInput({
33334
+ const literalText = await readLiteralInput({
33219
33335
  command: "message send",
33220
33336
  stdinSelected: opts.stdin === true,
33221
- stdin,
33222
- filePath: fileFlag,
33223
- fileOption: "--file <path>"
33337
+ stdin
33224
33338
  });
33339
+ const text2 = stripMalformedAlookHeredocTail(literalText ?? "");
33340
+ const textBytes = Buffer.byteLength(text2, "utf8");
33341
+ if (textBytes > CLIENT_MAX_MESSAGE_BODY_BYTES) {
33342
+ throw new CliError(`message send: --stdin body is ${textBytes} bytes; max ${CLIENT_MAX_MESSAGE_BODY_BYTES}. Rewrite it before retrying.
33343
+
33344
+ ${MESSAGE_SEND_STDIN_POLICY}`);
33345
+ }
33225
33346
  const attachmentIds = Array.isArray(opts.attachment) ? opts.attachment : [];
33226
- const hasText = typeof text2 === "string" && text2.trim().length > 0;
33347
+ const hasText = text2.trim().length > 0;
33227
33348
  if (!hasText && attachmentIds.length === 0) {
33228
- throw new CliError("message send: --stdin, --file <path>, or --attachment <id> is required");
33349
+ throw new CliError("message send: --stdin must contain text unless --attachment <id> is present");
33229
33350
  }
33230
33351
  let replyToSeq;
33231
33352
  const replyFlag = opts.reply;
@@ -33251,8 +33372,6 @@ async function cmdMessageSend(opts, stdin) {
33251
33372
  throw new CliError(`channel not aligned: ${res.unreadCount} unread message(s) in ${channel2} (latest #${res.latestSeq}). Run \`alook inbox pull\` and READ the new messages before deciding whether to resend, adjust, or skip your message.`);
33252
33373
  }
33253
33374
  const sent = `${res.message.channel}${res.message.seq}`;
33254
- if (remindAfterMs === undefined)
33255
- return { sent };
33256
33375
  const seqText = res.message.seq.replace(/^#/, "");
33257
33376
  const sentSeq = Number(seqText);
33258
33377
  if (!/^\d+$/.test(seqText) || !Number.isSafeInteger(sentSeq) || sentSeq < 1) {
@@ -33586,7 +33705,7 @@ function buildProgram(stdin) {
33586
33705
  }).option("--agent <id>", "agent identity (or ALOOK_AGENT_ID env)");
33587
33706
  const message2 = program.command("message").description("message operations").exitOverride();
33588
33707
  message2.configureOutput({ writeOut: () => {}, writeErr: () => {} });
33589
- message2.command("send").description("send a message to a channel, DM, or thread").option("--target <ref>", "destination (path-style ref, e.g. /demo-workspace#1234/general)").option("--stdin", "read the literal UTF-8 message body from non-TTY stdin").option("--file <path>", "read the literal UTF-8 message body from a file").option("-a, --attachment <id>", "attach an uploaded file by id (repeatable — order = message order)", (v, prev = []) => [...prev, v], []).option("--reply <seq>", 'reply to a message by its seq in --target (e.g. "#37" or 37)').option("--remind-after <duration>", "optionally arm one local follow-up wake after 1m..24h; a newer same-scope message or daemon restart cancels it").exitOverride().configureOutput({ writeOut: () => {}, writeErr: () => {} }).action(async function() {
33708
+ message2.command("send").description("send a message to a channel, DM, or thread").option("--target <ref>", "destination (path-style ref, e.g. /demo-workspace#1234/general)").requiredOption("--stdin", "read the required UTF-8 message body from non-TTY stdin (max 1 KiB)").option("-a, --attachment <id>", "attach an uploaded file by id (repeatable — order = message order)", (v, prev = []) => [...prev, v], []).option("--reply <seq>", 'reply to a message by its seq in --target (e.g. "#37" or 37)').requiredOption("--remind-after <0|Nm|Nh>", "required idle follow-up: 0 disables; 1m..24h arms/resets one same-scope timer; a newer message or daemon restart cancels it").exitOverride().configureOutput({ writeOut: () => {}, writeErr: () => {} }).action(async function() {
33590
33709
  const localOpts = this.opts();
33591
33710
  const globalOpts = program.opts();
33592
33711
  const result = await cmdMessageSend({ ...globalOpts, ...localOpts }, stdin);
package/dist/index.js CHANGED
@@ -6891,6 +6891,16 @@ function runtimeModelName(config) {
6891
6891
 
6892
6892
  // src/drivers/systemPrompt.ts
6893
6893
  var CLI = "$ALOOK_CLI";
6894
+ var MESSAGE_SEND_STDIN_POLICY = [
6895
+ "`--stdin` is required and limited to 1 KiB of UTF-8. Write it as social language a person " + "with ADHD can scan without effort:",
6896
+ "",
6897
+ "- Lead with the point, result, decision, or one concrete ask.",
6898
+ "- Keep one message to one topic; suppress tangents and repeated recap.",
6899
+ "- Use at most five short items when a list helps.",
6900
+ "- Drop preambles, play-by-play, and closing pleasantries.",
6901
+ "- Put exact plans, reviews, evidence, logs, and long technical detail in a Markdown attachment. " + "The message body carries only the short summary and next action."
6902
+ ].join(`
6903
+ `);
6894
6904
  function identitySection(config) {
6895
6905
  const parts = ["## Identity", ""];
6896
6906
  const name = config.agentName ?? "a member of the household";
@@ -6921,9 +6931,9 @@ function cliCommandsSection() {
6921
6931
  "",
6922
6932
  "### Messaging",
6923
6933
  "",
6924
- `1. \`${CLI} inbox pull\` — fetch unread messages (advances your read waterline by default, ` + `so they won't re-pull; \`--no-ack\` to peek without advancing).`,
6925
- `2. \`${CLI} message send\` — send to a channel, DM, or thread. ` + `For a short body, use explicit \`--stdin\` with a quoted heredoc; for a long or complicated body, ` + `use \`--file <path>\`. Attach with \`--attachment <id>\` (repeatable, order matters). Optionally add ` + `\`--remind-after <duration>\` to be reminded if the same channel, thread, or DM receives no ` + `newer message after your send. Use a whole number of minutes or hours from \`1m\` to \`24h\`, ` + `such as \`15m\` or \`2h\`.`,
6926
- `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>\`.`,
6934
+ `1. \`${CLI} inbox pull\` — fetch unread messages; \`--no-ack\` peeks without advancing.`,
6935
+ `2. \`${CLI} message send --target <ref> --remind-after <0|Nm|Nh> --stdin\` — send to a ` + `channel, DM, or thread. ` + `The body is required through \`--stdin\` and limited to 1 KiB of UTF-8. ` + `Attach uploaded files with \`--attachment <id>\` (repeatable, order matters). ` + `\`--remind-after\` accepts \`0\`, or a whole-number duration from \`1m\` to \`24h\`.`,
6936
+ `3. \`${CLI} message attachment upload --target <ref> --file <path>\` — upload a file; ` + `returns an id stable across pending→persisted.`,
6927
6937
  `4. \`${CLI} message attachment download --id <id> [--out <path>]\` — download any ` + `attachment you can see (or your own pending uploads).`,
6928
6938
  `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\`).`,
6929
6939
  `6. \`${CLI} message mark set --target <full-message-ref>\` — persist a message as outstanding work.`,
@@ -6953,7 +6963,7 @@ function cliCommandsSection() {
6953
6963
  "",
6954
6964
  "### Context Lifecycle",
6955
6965
  "",
6956
- `1. \`${CLI} nap --handoff <file>\` — 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.`,
6966
+ `1. \`${CLI} nap --handoff <file>\` — reset the current session from a required handoff file.`,
6957
6967
  "",
6958
6968
  "### Output format",
6959
6969
  "",
@@ -6969,14 +6979,29 @@ function messagingSection() {
6969
6979
  "",
6970
6980
  "### Sending & receiving",
6971
6981
  "",
6972
- "You can initiate conversations — send to any channel or DM someone directly. You're not " + "limited to replying. Use the same `message send` command whether you're replying or " + "starting a conversation.",
6982
+ "You can initiate conversations — send to any channel or DM someone directly. You're not " + "limited to replying; the same sending rules apply either way.",
6983
+ "",
6984
+ "#### Message body",
6985
+ "",
6986
+ MESSAGE_SEND_STDIN_POLICY,
6987
+ "",
6988
+ "#### Follow up when a conversation goes quiet",
6989
+ "",
6990
+ "`--remind-after T` is required on every send and controls whether a local follow-up is " + "armed for the same channel, thread, or DM.",
6973
6991
  "",
6974
- "- Reply where the message came from. Post results in the channel that owns the topic. " + "When uncertain, read history (below) or DM the relevant people.",
6975
- "Free-form message bodies never go in command arguments.",
6992
+ "- Use `1m` to `24h` when silence would leave work unfinished for example, after a question, " + "approval request, handoff, or blocker. If no newer message arrives by T, you will be reminded " + "to return; a newer message or daemon restart cancels the timer.",
6993
+ "- Use `0` only when no later action depends on a reply. It disables the timer.",
6976
6994
  "",
6977
- `- Short reply: use \`${CLI} message send --target <ref> --stdin\` with the quoted-heredoc form shown ` + "under *Message formatting*. Choose a fresh quoted delimiter that does not occur as a standalone line in the body.",
6978
- `- Long or complicated: write the body to a temporary file with a filesystem tool, then ` + `\`${CLI} message send --target <ref> --file ./temp_msg.md\`.`,
6979
- `- Cite a specific message: add \`--reply "#37"\` to either form — \`--reply\` takes the \`#N\` seq ` + "(within `--target`) of the message you're answering.",
6995
+ "Example: `--remind-after 5m` asks for a follow-up after five quiet minutes.",
6996
+ "",
6997
+ "#### Sending mechanics",
6998
+ "",
6999
+ `- Send body: use \`${CLI} message send --target <ref> --remind-after T --stdin\` with the ` + "quoted-heredoc form under *Message formatting*.",
7000
+ `- Long detail: upload it with \`${CLI} message attachment upload --target <ref> --file <path>.md\`; ` + "add the returned id as `--attachment <id>` on the short stdin send.",
7001
+ `- Cite a specific message: add \`--reply "#37"\` — \`--reply\` takes the \`#N\` seq ` + "(within `--target`) of the message you're answering.",
7002
+ "",
7003
+ "Reply where the message came from. Post results in the channel that owns the topic. " + "When uncertain, read history (below) or DM the relevant people.",
7004
+ "Write every message body in the stdin/heredoc block; never place it directly on the command line.",
6980
7005
  "",
6981
7006
  "### Context refs",
6982
7007
  "",
@@ -7010,14 +7035,14 @@ function messagingSection() {
7010
7035
  "",
7011
7036
  "### Message formatting",
7012
7037
  "",
7013
- "Alook renders specially formatted plain-text refs and mentions in message bodies. Write them " + "as plain text, not inside backticks.",
7038
+ "Alook specially renders refs and mentions in message bodies. Write them as plain text, not " + "inside backticks.",
7014
7039
  "",
7015
- "- **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.",
7040
+ "- **Context refs** — write the full refs from *Context refs* above so they stay " + "clickable. Never paste a private DM ref into a server channel.",
7016
7041
  "- **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.`,
7017
7042
  "",
7018
7043
  "```bash",
7019
7044
  "# Choose a fresh quoted delimiter that does not occur as a standalone line in the body.",
7020
- `${CLI} message send --target "/demo#1234/general" --stdin <<'ALOOK_MESSAGE_7F3C'`,
7045
+ `${CLI} message send --target "/demo#1234/general" --remind-after 5m --stdin <<'ALOOK_MESSAGE_7F3C'`,
7021
7046
  "@alice#0001 Please review /demo#1234/general#42",
7022
7047
  "ALOOK_MESSAGE_7F3C",
7023
7048
  "```",
@@ -7030,7 +7055,7 @@ function messagingSection() {
7030
7055
  "```",
7031
7056
  "",
7032
7057
  "`channel` is the reply ref. `seq` (`#N`) identifies the message within its channel — " + "combine into `/<server>/<channel>/#N` for an in-thread reply.",
7033
- "`content.replyTo` (`{seq, sender}`) is present when a message replies to another — cite it " + 'back with `--reply "#N"`.',
7058
+ "`content.replyTo` (`{seq, sender}`) identifies the message being replied to.",
7034
7059
  "`hint` is present when the containing surface changes how you should act. Follow it."
7035
7060
  ].join(`
7036
7061
  `);
@@ -7043,15 +7068,15 @@ function channelTypesSection() {
7043
7068
  "",
7044
7069
  "### Text channels",
7045
7070
  "",
7046
- "- A text channel is a linear conversation. Send directly to its ref with `message send --target " + "/<server>/<channel>`.",
7071
+ "- A text channel is a linear conversation. Send with `message send --target " + "/<server>/<channel>`.",
7047
7072
  "- A text-channel message may have a side thread at `/<server>/<channel>/#N`. Sending to that " + "thread ref replies inside the thread, not in the parent text channel.",
7048
7073
  "",
7049
7074
  "### Forum channels",
7050
7075
  "",
7051
7076
  "- A forum is a collection of posts, not one linear conversation.",
7052
7077
  "- Each top-level message in a forum is a post title. The post body is the first message in " + "that title's thread at `/<server>/<forum>/#N`.",
7053
- "- To participate in the discussion, reply to that thread with `message send`. Inside the " + "thread, messaging works like a normal text channel.",
7054
- "- To publish your own post, first send its title as a new message in the forum, then send " + "the body as the first message in the corresponding thread."
7078
+ "- To participate in a post, use `message send --target /<server>/<forum>/#N`.",
7079
+ "- To publish a post, use `message send --target /<server>/<forum>` for its title, then " + "`message send --target /<server>/<forum>/#N` for the body."
7055
7080
  ].join(`
7056
7081
  `);
7057
7082
  }
@@ -7079,13 +7104,7 @@ function utilsSection() {
7079
7104
  "",
7080
7105
  "### Join a new server",
7081
7106
  "",
7082
- `If a message contains a \`/c/invite/...\` link, just run \`${CLI} server join --invite <link>\`. ` + "The server enforces owner-only: it accepts only invites your owner created and rejects the " + "rest with a reason. Safe to attempt without reasoning about who sent it.",
7083
- "",
7084
- "### Follow up when a conversation goes quiet",
7085
- "",
7086
- `Use \`message send --remind-after <duration>\` when you send something that may need a later ` + "follow-up — for example, a question, approval request, handoff, or blocker — and silence would " + "leave the work unfinished. If no newer message appears in that channel, thread, or DM during " + "the duration, you'll receive a reminder to return and decide what to do next. Don't add it to " + "ordinary messages that need no follow-up.",
7087
- "",
7088
- `Example: ${CLI} message send --target "/demo#1234/team" --remind-after 1m --file ./message.md`
7107
+ `If a message contains a \`/c/invite/...\` link, just run \`${CLI} server join --invite <link>\`. ` + "The server enforces owner-only: it accepts only invites your owner created and rejects the " + "rest with a reason. Safe to attempt without reasoning about who sent it."
7089
7108
  ].join(`
7090
7109
  `);
7091
7110
  }
@@ -7093,11 +7112,10 @@ function criticalRulesSection() {
7093
7112
  return [
7094
7113
  "## Critical rules",
7095
7114
  "",
7096
- `- **\`${CLI}\` is the only way to communicate.** Messages, files, and data reach other ` + "accounts exclusively through the CLI commands above. Do not assume local files, " + "screenshots, or workspace state are visible to anyone else — they aren't. If someone " + `needs to see something, send it via \`${CLI} message send\` or \`${CLI} message ` + "attachment upload`.",
7115
+ `- **\`${CLI}\` is the only way to communicate.** Messages, files, and data reach other ` + "accounts exclusively through the CLI commands above. Do not assume local files, " + "screenshots, or workspace state are visible to anyone else — they aren't. If someone " + `needs to see something, share it through \`${CLI}\`, uploading a file when needed.`,
7097
7116
  "- **Never expose tokens, keys, or secrets.** Redact credential-like strings from tool output " + "before sharing.",
7098
7117
  "- **Match the sender's language.** Reply in the language they wrote in.",
7099
- "- **Channel alignment**: you can't send to a channel with unread messages. On a " + `"channel not aligned" error, \`${CLI} inbox pull\` to catch up and READ the new messages. ` + "Judge if your message is still needed or overlaps with what just landed. Adjust or skip; " + "don't mechanically resend.",
7100
- "- **Finish in-flight work before stopping.** Don't leave anything half-handled. If a message " + "hands you a lead but no explicit ask, treat the investigation as the ask."
7118
+ "- **Channel alignment**: you can't send to a channel with unread messages. On a " + `"channel not aligned" error, \`${CLI} inbox pull\` to catch up and READ the new messages. ` + "Judge if your message is still needed or overlaps with what just landed. Adjust or skip; " + "don't mechanically resend."
7101
7119
  ].join(`
7102
7120
  `);
7103
7121
  }
@@ -7105,7 +7123,7 @@ function executionModelSection() {
7105
7123
  return [
7106
7124
  "## How you work — async, not turn-based",
7107
7125
  "",
7108
- "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.",
7126
+ "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. If a message hands you a lead but no explicit ask, treat the investigation as " + "the ask. Stop only when all of it is done.",
7109
7127
  "",
7110
7128
  "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.",
7111
7129
  "",
@@ -25792,7 +25810,7 @@ function parseLocalMessageReminderBody(body, agentId) {
25792
25810
  return null;
25793
25811
  if (!Number.isSafeInteger(record4.sentSeq) || record4.sentSeq < 1)
25794
25812
  return null;
25795
- if (!Number.isSafeInteger(record4.remindAfterMs) || record4.remindAfterMs < LOCAL_MESSAGE_REMINDER_MIN_MS || record4.remindAfterMs > LOCAL_MESSAGE_REMINDER_MAX_MS)
25813
+ if (!Number.isSafeInteger(record4.remindAfterMs) || record4.remindAfterMs !== 0 && record4.remindAfterMs < LOCAL_MESSAGE_REMINDER_MIN_MS || record4.remindAfterMs > LOCAL_MESSAGE_REMINDER_MAX_MS)
25796
25814
  return null;
25797
25815
  return {
25798
25816
  agentId,
@@ -27630,6 +27648,10 @@ class MessageReminderScheduler {
27630
27648
  }
27631
27649
  arm(input) {
27632
27650
  const key = reminderKey(input.agentId, input.channel);
27651
+ if (input.remindAfterMs === 0) {
27652
+ this.clearReminder(key);
27653
+ return { armed: false, reason: "disabled" };
27654
+ }
27633
27655
  const latest = this.latestObservedSeq.get(key);
27634
27656
  if (latest !== undefined && latest > input.sentSeq) {
27635
27657
  return { armed: false, reason: "newer_message_observed" };
@@ -27857,6 +27879,78 @@ function createDaemonAgentDriverHost(ctx, onRawLine) {
27857
27879
  };
27858
27880
  }
27859
27881
 
27882
+ // src/daemon/daemonSelfSleep.ts
27883
+ var DAEMON_SELF_SLEEP_TIMEOUT_MS = 15 * 24 * 60 * 60 * 1000;
27884
+ var systemClock = {
27885
+ setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
27886
+ clearTimer: (timer) => clearTimeout(timer)
27887
+ };
27888
+
27889
+ class DaemonSelfSleepScheduler {
27890
+ opts;
27891
+ clock;
27892
+ workingAgents = new Set;
27893
+ timer = null;
27894
+ generation = 0;
27895
+ started = false;
27896
+ stopped = false;
27897
+ constructor(opts) {
27898
+ this.opts = opts;
27899
+ this.clock = opts.clock ?? systemClock;
27900
+ }
27901
+ start() {
27902
+ if (this.started || this.stopped)
27903
+ return;
27904
+ this.started = true;
27905
+ this.arm();
27906
+ }
27907
+ observeMessage() {
27908
+ if (!this.started || this.stopped)
27909
+ return;
27910
+ this.arm();
27911
+ }
27912
+ observeAgentActivity(agentId, working) {
27913
+ if (this.stopped)
27914
+ return;
27915
+ if (working) {
27916
+ this.workingAgents.add(agentId);
27917
+ if (this.started)
27918
+ this.cancel();
27919
+ return;
27920
+ }
27921
+ if (this.workingAgents.delete(agentId) && this.started && this.workingAgents.size === 0)
27922
+ this.arm();
27923
+ }
27924
+ stop() {
27925
+ if (this.stopped)
27926
+ return;
27927
+ this.stopped = true;
27928
+ this.workingAgents.clear();
27929
+ this.cancel();
27930
+ }
27931
+ arm() {
27932
+ this.cancel();
27933
+ if (this.workingAgents.size > 0)
27934
+ return;
27935
+ const generation = this.generation;
27936
+ this.timer = this.clock.setTimer(() => {
27937
+ if (this.stopped || this.generation !== generation || this.workingAgents.size > 0)
27938
+ return;
27939
+ this.timer = null;
27940
+ this.generation += 1;
27941
+ this.opts.onSleep();
27942
+ }, DAEMON_SELF_SLEEP_TIMEOUT_MS);
27943
+ this.timer.unref?.();
27944
+ }
27945
+ cancel() {
27946
+ this.generation += 1;
27947
+ if (!this.timer)
27948
+ return;
27949
+ this.clock.clearTimer(this.timer);
27950
+ this.timer = null;
27951
+ }
27952
+ }
27953
+
27860
27954
  // src/daemon/createDaemon.ts
27861
27955
  var WARMUP_BACKOFF_MS = [250, 500, 1000, 2000, 4000];
27862
27956
  var WARMUP_CEILING_MS = 30000;
@@ -28013,6 +28107,10 @@ async function createDaemon(opts) {
28013
28107
  let channelRef = null;
28014
28108
  let managerRef = null;
28015
28109
  let reminderSchedulerRef = null;
28110
+ const selfSleepScheduler = opts.onSelfSleep ? new DaemonSelfSleepScheduler({
28111
+ onSleep: opts.onSelfSleep,
28112
+ ...opts.selfSleepClock ? { clock: opts.selfSleepClock } : {}
28113
+ }) : null;
28016
28114
  const emitBotAuditEvent = (agentId, event, context) => {
28017
28115
  channelRef?.reportBotAuditEvent?.({
28018
28116
  type: "bot_audit_event",
@@ -28306,6 +28404,7 @@ async function createDaemon(opts) {
28306
28404
  tickIntervalMs: opts.tickIntervalMs ?? 2000,
28307
28405
  onAgentSession: (info) => void channel2.reportAgentSession(info),
28308
28406
  onAgentActivity: (info) => {
28407
+ selfSleepScheduler?.observeAgentActivity(info.agentId, info.state === "running");
28309
28408
  channel2.reportAgentActivity?.(info);
28310
28409
  if (info.state === "starting" || info.state === "running") {
28311
28410
  if (!typingHeartbeats.has(info.agentId)) {
@@ -28383,6 +28482,7 @@ async function createDaemon(opts) {
28383
28482
  reportDiagnosticFailure: opts.reportDiagnosticFailure
28384
28483
  }));
28385
28484
  channel2.onWakeDesiredAdvance((cmd) => {
28485
+ selfSleepScheduler?.observeMessage();
28386
28486
  reminderSchedulerRef?.observe(cmd.agentId, cmd.unreadNotice.channel, cmd.unreadNotice.latestSeq);
28387
28487
  });
28388
28488
  channel2.onCommand((cmd) => {
@@ -28407,6 +28507,7 @@ async function createDaemon(opts) {
28407
28507
  });
28408
28508
  channel2.connect();
28409
28509
  await router.start();
28510
+ selfSleepScheduler?.start();
28410
28511
  return {
28411
28512
  isOpen: () => channel2.status === "open",
28412
28513
  onOpen: (hook) => {
@@ -28416,6 +28517,7 @@ async function createDaemon(opts) {
28416
28517
  },
28417
28518
  proxyUrl: proxy.url,
28418
28519
  stop: async () => {
28520
+ selfSleepScheduler?.stop();
28419
28521
  reminderSchedulerRef?.clearAll();
28420
28522
  for (const agentId of [...typingHeartbeats.keys()]) {
28421
28523
  emitTypingStopsAndClear(agentId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alook/daemon",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
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",