Skip to content

v0.9.0

Latest

Choose a tag to compare

@clawcrab clawcrab released this 03 Sep 05:00
· 21 commits to main since this release
2d3e890

Breaking changes

  • The BlueBubbles iMessage backend is removed (#290). imsg replaced it on 2026-07-07; src/channels/imessage.ts, the channels.imessage.url / password / webhookPort settings and their IMESSAGE_* env overrides are gone. channels.imessage.provider survives as the on/off signal but now defaults to unset (it used to fall back to "bluebubbles"), so iMessage is off unless asked for; a config still pinned to "bluebubbles" raises one targeted startup issue naming the fix.

  • Outbound delivery is block-based, not token-streamed (#292, #293). Each completed content block ships as soon as it closes, so text written before a long tool call reaches the user without waiting for the turn to end. Delivery is decided by block type rather than by pattern-matching prose, and NO_REPLY now suppresses only its own block — it cannot retract text already delivered earlier in the turn.

  • The legacy [[NL]] marker is no longer converted on any outlet (#340). Newlines in a block are delivered as-is, and the system prompt no longer teaches the marker.

Features

  • People records carry an optional timezone (#343). Frontmatter takes an IANA region identifier (timezone: Asia/Tokyo); upsert_person writes it and list_people reports it. The prompt-cached roster gets the identifier only, while the per-message inbound stamp carries the live reading ([imessage · Wed 09/02 20:50 PDT · sender 09/03 12:50 GMT+9]), omitted when the sender has no zone or reads the same clock as the host. Fixed offsets, Etc/* and the all-caps aliases (EST, UTC, …) are refused because they carry no DST rules — ICU resolves EST to a zone that never observes daylight saving, which would read an hour wrong all summer. An invalid stored value renders as no time zone and is reported to the model as timezone_invalid so it can repair the record. Handling a message now reads the people registry once through a request-scoped snapshot instead of twice per group message.

  • Fabricated inbound markers in outgoing text are flagged instead of trusted (#314). The model sometimes writes a line shaped like a fresh inbound message — [imessage · …], [group "Family"] kw: …, <tomo-event …>, System: — inside its own reply and then answers it as if the owner had typed it. src/agent/inbound-markers.ts flags any such line that starts a line outside a code fence, so quoting or pasting these markers stays safe. Policy is mark, don't truncate: the block ships whole with one advisory line prepended to the wire copy only — the transcript, the error classifier and the silence policy all keep the model's verbatim words. Both model-authored outlets run it (reply blocks and send_message direct mode); each hit logs and increments tomo_fabricated_markers_total{shape}. The stamp and group-tag formatters moved into the same module, so detector and producer cannot drift.

  • schedule_enable MCP tool. Enables or disables a scheduled task by id without deleting and re-creating it — the only way back for a job the daemon disabled after an interrupted run. Enabling clears the interrupted state, but only when that state is genuinely settled, so a toggle during a live resumed run cannot erase the evidence that the job was dispatched.

  • Inbound attachments of any MIME type are stored instead of dropped (#289). A .zip sent over iMessage used to reach the agent as a bare object-replacement character — no text, no marker, nothing. Unsupported types are now copied to memory/incoming-files/YYYY-MM-DD/… and announced in a one-line notice with name, type, size and path. The path is deliberately all the model gets: the bytes are not attached to the message or uploaded to the API, and the agent opens the file with Read/Bash when it wants it. Sender-controlled filenames and MIME types are sanitised so neither can escape the directory or forge a marker, a file that never downloaded reports why instead of vanishing, and MIME-less rows are skipped only on a positive link-preview discriminator (of 1,287 rows in a live chat.db, 181 had no MIME type and 19 of those were real files).

  • saveInboundFiles config key and TOMO_SAVE_INBOUND_FILES override. Gates the any-MIME store independently of saveInboundImages, defaulting to that key's value so an install that already opted out stays opted out. Turning it off still tells the agent a file arrived, just without a path.

  • A workspace path that cannot be rendered in an attachment notice is rejected at config load. TOMO_WORKSPACE is free-form, and a path with a newline or ] in it would split or truncate the single-line notice. Refusing at load beats neutralising the path, which would name a file that does not exist.

  • A rollup promotion that would replace an existing block says so (#213). The daily nudge now reports replacesExistingBlock and tells the model the command overwrites rather than appends, and to carry the existing block's content into the fresh summary.

Bug fixes

  • The daemon installs unhandledRejection / uncaughtException handlers at start (#322). There were none, so one orphaned promise killed the process with none of shutdown()'s cleanup. An unhandled rejection is now logged and survived (rate-limited to 5 per minute, since each one fans out to every tomo watch client); an uncaught exception is logged and exits 1, after one synchronous salvage step that records unprocessed inbound so a received message never becomes a silent non-answer. A rejected start action takes the same fatal path instead of leaving a half-started daemon holding the pid file.

  • The daemon claims its PID file atomically as its first act, and tomo stop waits for the exit it reports (#318). The file used to be written at the end of startup behind an existsSync check, so an autostart racing a manual tomo start left two daemons running. acquirePidFile() publishes it by link() under a lock directory, records the process start time so a recycled pid reads as stale rather than as a permanent refusal, and every reader shares one isPidAlive. tomo stop no longer prints success on the strength of having sent SIGTERM: it waits out a shared 60 s budget (10 s reported healthy mid-turn shutdowns as failures), and background tomo start waits until the pid file names its child before reporting started.

  • The legacy [[NL]] marker can no longer reach a channel (#336). send_message direct mode and edit_message skipped the rewrite every reply block gets, so a cron morning brief reached iMessage with AI[[NL]]· … in it. Both outlets now run the same rewrite, and the transcript records what was actually sent.

  • A cron job interrupted by a restart is no longer silently fired a second time (#313). nextRunAt only advanced on completion and the "already dispatched" guard was in-memory, so a daemon that went down mid-run came back and ran the job again — observed 2026-08-20, 39 seconds apart. Dispatch is now recorded durably before the turn starts (markStarted writes a run token that completion acknowledges), and CronScheduler.start() settles unacknowledged runs before its first due-scan: a recurring job fires once with a [resumed] note telling the model to check for side effects, a one-shot is disabled rather than fired again. Recovery is a precondition for scanning, a failed write after a successful turn is no longer recorded as a failed run, resumes are capped at three, and a run handed to a busy summoned session is only marked complete once it actually runs.

  • The cron store no longer loses writes or reads an unreadable file as empty. Every save is a three-way merge against the file at write time under an optimistic revision re-checked immediately before the publishing rename, so a tomo cron process holding a stale snapshot cannot erase a dispatch record. A read or parse failure throws instead of returning [] (which let recovery report success with nothing to recover, then publish an empty store over the real jobs); the store still constructs, refuses to read or write until a later load succeeds, and every surface degrades in its own way — tomo status and /metrics render the rest of the report with the cron section marked unreadable rather than dying.

  • tomo config no longer writes {} over a config file it could not parse (#315). loadConfig() returned {} for any failure, and every configXxx() is load → mutate → save, so editing the model on an unparseable config persisted {"model":"…"} and dropped the token, allowlist, identities and MCP servers — while printing success. Loading now distinguishes absent from unreadable, saving re-checks the file before the backup rotation so a .bak is only ever taken from a file that parsed, and the submenus that never read config.json stay usable.

  • A compact, prune or transcript rotation no longer deletes JSONL lines it could not parse (#320). Three read-modify-rewrite call sites emitted only what parsed, so a mid-file tear from a power loss was made permanent by the next tomo lcm daily. parseJsonl(text, { preserveUnparseable: true }) now carries each bad line verbatim in place and serializeJsonlRecord writes it back byte-for-byte; the opt-in widens the element type so the compiler forces every rewriting caller to narrow first, and read-only callers keep the old skip-and-continue behaviour. Carried lines never enter the conversation index or anchor a compaction range, each is logged once per file and line, and --drop-unparseable is the documented escape hatch.

  • A session registry that could not be read is no longer persisted as an empty one (#317). A bare catch set the registry to [] for any failure, and the next updateStats() published {version:1,sessions:[]} — every conversation cold-started, the old JSONLs were orphaned and could never be TTL'd, with no log line at all. loadRegistry() now separates ENOENT from a read failure and keeps the last known-good state in memory. Mutators are classed by what refusing costs: bookkeeping (updateStats, touchSession, setChatTitle, addParticipant, setReplyTarget) skips silently, because it sits on the inbound and turn-completion paths where a throw drops a message or fails a good turn; link-changing (setSdkSessionId, clearSdkSessionId, retireSdkSessionId, migrateSessionKey) throws SessionRegistryReadError. Both guard before mutating, so nothing is left applied-but-unsaved.

  • A question asked during a silent turn is no longer answered into a void (#301). Harness turns with suppressDelivery drop their reply text, so a user message steered into one was answered into the log. The steered message now carries a note saying its reply will not be delivered and to use send_message — targeted at the audience the message came from, not the session running the turn, so a summoned group's question is not answered privately to the owner. A mixed batch pairs each target with the message's ordinal rather than quoting sender text, which cannot be forged. Silent cron turns also carry the same "your reply text is not delivered" sentence continuity turns have, written inside the <tomo-event> envelope so LCM does not read it as conversation.

  • A queued group mention is no longer lost at shutdown (#295, #338). The sweep of queuedInbound runs once, so a mention accepted after it was parked in a map nothing looks at again — acknowledged by the channel and gone. Such an item is now recorded instead of parked, on both the quiesce and the crash path, and queued inbound merges ahead of the batcher's items so the drained transcript reads in acceptance order.

  • iMessage threads photos, and a reply target is never lost to a send that didn't happen. ImsgChannel sends threaded attachments through send.attachment with reply_to, and because threading is per message kind (stickers never thread), Channel.send can report threaded: false so the pipeline hands the target to the next message instead of considering it spent. Every fallback out of ImsgChannel.send reports it too, and a caption is offered the target its picture could not take — which is the whole turn when the block is a final caption + MEDIA:path.

  • A thinking block with text in it is delivered as a message when showThinking is off (#297). Under display: "omitted" an empty thinking block is normal and still dropped; a non-empty one is the model having written its reply in the wrong block type. One session's transcript held 173 empty thinking blocks and 21 with text — all 21 were prose aimed at the owner, six of them answers he never got. Such a block is now rendered exactly like a text block, decided from block type and length alone.

  • A picture that fails to send no longer takes its caption with it. captionSent was set on having called send, so a file that disappeared between the pipeline's existsSync and the channel's open lost the image, the text, and any STICKER: behind it. Both channels now raise a typed pre-flight AttachmentUnreadableError, the caption goes out as a normal text send, and the transcript records the caption verbatim followed by [delivery failed] MEDIA:…. The fallback is limited to failures that provably happened before dispatch — an ambiguous timeout may already have landed the picture, so re-sending the caption would be a duplicate.

  • One corrupt record no longer truncates a transcript recall (#312 finding 37). searchTranscript ended its scan at the first record below the lower bound, and an unusable position coerced into a real one — a missing seq became 0, a seconds-precision timestamp read as ancient — so one bad record abandoned the rest of the file and every archive behind it. A record whose position cannot be established is now skipped rather than used to end the scan.

  • A failed pet-state write no longer takes the daemon down (#312 finding 15). PetScheduler.tick() ran PetStore.save() unguarded from both start() and its hourly interval, so ENOSPC or EACCES killed every channel and the imsg rpc child because a toy pet could not write its state file. The tick is wrapped and logged, and the interval is unref'd like every other background timer.

  • One bad metrics field no longer discards the whole block (#312 finding 43). The whole object fell back on a single invalid field, and the defaults turn activityLog and includeMessageText back on — so a typo in port would have re-enabled writing transcript text into activity.ndjson for someone who deliberately turned it off. Each field is now validated on its own and raises its own named issue.

  • splitText no longer cuts astral characters in half. Long messages were split by UTF-16 code unit, which could emit a lone surrogate; splitting is now grapheme-safe, including at pathologically small limits.

  • Every sips invocation is bounded by a deadline (#312 finding 10). One hostile HEIC could wedge the inbound FIFO — and quiesce() — forever.

  • Transcript rotation is serialized and no longer erases concurrent appends (#312 finding 5), a restore is staged and verified before it touches the live directories (#312 finding 4), an inbound image or document never overwrites an existing file (#312 finding 9), and tomo backup's two unvalidated inputs are checked (#310) — TOMO_BACKUP_RETENTION_DAYS=0 pruned every local backup.

  • Assorted daemon-lifetime fixes. The rollup nudge cooldown persists across restarts (#311) and no longer re-nudges the same period as the tail advances; VersionChecker / RollupRunner cancel their initial-delay check in stop(); the post-update restart command is no longer built through a shell; session-initiated restarts are deferred (#278); Telegram's unsupported-document notice sanitises its sender-controlled fields; and the showThinking config reference points at tomo restart rather than a /reset command that does not exist.

Security

  • Credentials no longer reach tomo status, the launchd error log, or tomo.log. Three paths, one shared rule in the new src/redact.ts. configIssues stringified the whole channel entry it was complaining about — one bad allowlist printed the bot token into a file that goes into bug reports. The pino message carried live tool output: summarizeToolResult logs the first 500 characters of every tool result, so a single Read ~/.tomo/config.json wrote credentials into tomo.log, defeating the 0600 on the token store. And logger.ts had no redact config at all; it now scrubs by field name at any depth, through a wrapped err serializer, and by value shape for credentials that sit under no key at all. The scrubber is deliberately constrained so it cannot mangle prose — token: null and "access token expired." are pinned as tests, because a scrubber that damages logs is one that gets turned off — and the deep hook is written to be unable to take a log call down (no getter invocation, depth cap, revoked-proxy safe).

  • tomo config no longer prints the Telegram bot token to the terminal. The bot-token prompt used p.text with the full token pre-filled; it is now p.password displaying (set), with a blank answer keeping the existing value.

  • A blank environment variable no longer overrides the config file (#312 finding 42). Seven overrides read process.env.FOO ?? file.foo, which only falls through on undefined — so TELEGRAM_BOT_TOKEN= blanked a configured token while the startup error pointed at the variable that is set, and TOMO_WORKSPACE= made whatever directory the daemon was launched from the workspace. All of them now go through envVar(), and tomo start names the ignored variables once, on a good boot and after a failed assertion alike. Behaviour change: CLAUDE_MODEL="" used to block startup and now boots on the configured or default model.

  • The cron MCP tools are bound to the calling session (#319). The cron store is one flat file shared by every session, so a group chat could schedule_list and receive the full message text of every reminder the owner had scheduled privately, schedule_remove any id it was handed, or schedule_create into dm:owner with a crafted message. All four tools now share canManageJob: a session manages its own jobs, a dm: caller additionally manages group-keyed and session-less ones. schedule_list reports a bare count of what it cannot see (silence would have the model tell the user there are none), schedule_create's session defaults to the caller and is validated, and ownership is re-checked inside each store write rather than against a snapshot. Audience is tracked per turn by TurnAudienceRegistry, not per session, because steering runs two turns concurrently on one key; overlapping turns that disagree fail closed.

  • A delegated turn carries the audience of the turn that asked for it (#335). send_message(mode: "delegate") dispatches a turn onto the target session, and only runUserTurn registered an audience — so a group participant could delegate into dm:owner and have every cron tool in that turn run as the owner. The caller's audience is now resolved while the calling turn is still live and handed down; a caller whose own turn is unattributable is refused rather than run under a guess. The same hole from the other side — a group-keyed background turn handed to the summoning dm: session — is closed the same way.

  • Private people records and transcript recall are disabled while a group is summoned into a session (#328). /summon routes a group's messages onto the owner's dm: session and isGroupSessionKey("dm:owner") is false, so list_people returned the memory/private/people/ subtree and recall_conversation searched the owner's entire DM history on a phrase a group participant chose. Both families now resolve the turn's audience through Agent.isOwnAudienceTurn, with includePrivate and the new canSearch gate as per-call getters. Recall refuses outright for such a turn rather than searching the group's own transcript, which is stale for exactly that period. The prompt reminder states the norm ("the owner's private context is off limits for this turn") instead of naming disabled tools.

  • Files under memory/private/ are unreadable while a group is summoned (#334). The PreToolUse guard was only installed for group sessions, so on a summoned turn Read memory/private/notes.md simply went through. It is now installed for every session and its verdict resolved per tool call. Several containment gaps went with it: comparison is case-folded (APFS is case-insensitive and this is a deny predicate, so failing to establish containment meant allowed), Glob/Grep patterns are probed absolutely as well as relatively, a path landing in private/ after symlink resolution is denied, and a .. under memory/ is refused outright rather than normalised. The Bash arm additionally refuses expanding globs, find … -exec and pipes into readers, recursive grep, any rg/ag/ack, and archive verbs — each reads a tree while naming no path the other rules can see.

Dependencies

  • Bump @anthropic-ai/claude-agent-sdk 0.3.232 → 0.3.258 (#285, #287, #298, #341, #342).
  • Bump grammy 1.45.1 → 1.46.0, plus grouped dev-dependency updates (eslint, typescript-eslint).