Breaking changes
-
The BlueBubbles iMessage backend is removed (#290).
imsgreplaced it on 2026-07-07;src/channels/imessage.ts, thechannels.imessage.url/password/webhookPortsettings and theirIMESSAGE_*env overrides are gone.channels.imessage.providersurvives 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_REPLYnow 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_personwrites it andlist_peoplereports 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 resolvesESTto 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 astimezone_invalidso 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.tsflags 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 andsend_messagedirect mode); each hit logs and incrementstomo_fabricated_markers_total{shape}. The stamp and group-tag formatters moved into the same module, so detector and producer cannot drift. -
schedule_enableMCP 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
.zipsent over iMessage used to reach the agent as a bare object-replacement character — no text, no marker, nothing. Unsupported types are now copied tomemory/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 withRead/Bashwhen 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). -
saveInboundFilesconfig key andTOMO_SAVE_INBOUND_FILESoverride. Gates the any-MIME store independently ofsaveInboundImages, 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_WORKSPACEis 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
replacesExistingBlockand 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/uncaughtExceptionhandlers at start (#322). There were none, so one orphaned promise killed the process with none ofshutdown()'s cleanup. An unhandled rejection is now logged and survived (rate-limited to 5 per minute, since each one fans out to everytomo watchclient); 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 rejectedstartaction 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 stopwaits for the exit it reports (#318). The file used to be written at the end of startup behind anexistsSynccheck, so an autostart racing a manualtomo startleft two daemons running.acquirePidFile()publishes it bylink()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 oneisPidAlive.tomo stopno 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 backgroundtomo startwaits until the pid file names its child before reporting started. -
The legacy
[[NL]]marker can no longer reach a channel (#336).send_messagedirect mode andedit_messageskipped the rewrite every reply block gets, so a cron morning brief reached iMessage withAI[[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).
nextRunAtonly 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 (markStartedwrites a run token that completion acknowledges), andCronScheduler.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
revisionre-checked immediately before the publishing rename, so atomo cronprocess 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 statusand/metricsrender the rest of the report with the cron section marked unreadable rather than dying. -
tomo configno longer writes{}over a config file it could not parse (#315).loadConfig()returned{}for any failure, and everyconfigXxx()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.bakis only ever taken from a file that parsed, and the submenus that never readconfig.jsonstay 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 andserializeJsonlRecordwrites 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-unparseableis the documented escape hatch. -
A session registry that could not be read is no longer persisted as an empty one (#317). A bare
catchset the registry to[]for any failure, and the nextupdateStats()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) throwsSessionRegistryReadError. 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
suppressDeliverydrop 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 usesend_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
queuedInboundruns 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.
ImsgChannelsends threaded attachments throughsend.attachmentwithreply_to, and because threading is per message kind (stickers never thread),Channel.sendcan reportthreaded: falseso the pipeline hands the target to the next message instead of considering it spent. Every fallback out ofImsgChannel.sendreports it too, and a caption is offered the target its picture could not take — which is the whole turn when the block is a finalcaption + MEDIA:path. -
A
thinkingblock with text in it is delivered as a message whenshowThinkingis off (#297). Underdisplay: "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 atextblock, decided from block type and length alone. -
A picture that fails to send no longer takes its caption with it.
captionSentwas set on having calledsend, so a file that disappeared between the pipeline'sexistsSyncand the channel's open lost the image, the text, and anySTICKER:behind it. Both channels now raise a typed pre-flightAttachmentUnreadableError, 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).
searchTranscriptended its scan at the first record below the lower bound, and an unusable position coerced into a real one — a missingseqbecame0, 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()ranPetStore.save()unguarded from bothstart()and its hourly interval, soENOSPCorEACCESkilled every channel and theimsg rpcchild because a toy pet could not write its state file. The tick is wrapped and logged, and the interval isunref'd like every other background timer. -
One bad
metricsfield no longer discards the whole block (#312 finding 43). The whole object fell back on a single invalid field, and the defaults turnactivityLogandincludeMessageTextback on — so a typo inportwould have re-enabled writing transcript text intoactivity.ndjsonfor someone who deliberately turned it off. Each field is now validated on its own and raises its own named issue. -
splitTextno 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
sipsinvocation is bounded by a deadline (#312 finding 10). One hostile HEIC could wedge the inbound FIFO — andquiesce()— 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=0pruned 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/RollupRunnercancel their initial-delay check instop(); 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 theshowThinkingconfig reference points attomo restartrather than a/resetcommand that does not exist.
Security
-
Credentials no longer reach
tomo status, the launchd error log, ortomo.log. Three paths, one shared rule in the newsrc/redact.ts.configIssuesstringified the whole channel entry it was complaining about — one badallowlistprinted the bot token into a file that goes into bug reports. The pino message carried live tool output:summarizeToolResultlogs the first 500 characters of every tool result, so a singleRead ~/.tomo/config.jsonwrote credentials intotomo.log, defeating the0600on the token store. Andlogger.tshad noredactconfig at all; it now scrubs by field name at any depth, through a wrappederrserializer, and by value shape for credentials that sit under no key at all. The scrubber is deliberately constrained so it cannot mangle prose —token: nulland "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 configno longer prints the Telegram bot token to the terminal. The bot-token prompt usedp.textwith the full token pre-filled; it is nowp.passworddisplaying(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 — soTELEGRAM_BOT_TOKEN=blanked a configured token while the startup error pointed at the variable that is set, andTOMO_WORKSPACE=made whatever directory the daemon was launched from the workspace. All of them now go throughenvVar(), andtomo startnames 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_listand receive the full message text of every reminder the owner had scheduled privately,schedule_removeany id it was handed, orschedule_createintodm:ownerwith a crafted message. All four tools now sharecanManageJob: a session manages its own jobs, adm:caller additionally manages group-keyed and session-less ones.schedule_listreports a bare count of what it cannot see (silence would have the model tell the user there are none),schedule_create'ssessiondefaults 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 byTurnAudienceRegistry, 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 onlyrunUserTurnregistered an audience — so a group participant could delegate intodm:ownerand 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 summoningdm:session — is closed the same way. -
Private people records and transcript recall are disabled while a group is summoned into a session (#328).
/summonroutes a group's messages onto the owner'sdm:session andisGroupSessionKey("dm:owner")is false, solist_peoplereturned thememory/private/people/subtree andrecall_conversationsearched the owner's entire DM history on a phrase a group participant chose. Both families now resolve the turn's audience throughAgent.isOwnAudienceTurn, withincludePrivateand the newcanSearchgate 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 turnRead memory/private/notes.mdsimply 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 inprivate/after symlink resolution is denied, and a..undermemory/is refused outright rather than normalised. The Bash arm additionally refuses expanding globs,find … -execand pipes into readers, recursivegrep, anyrg/ag/ack, and archive verbs — each reads a tree while naming no path the other rules can see.