You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Lands after #93, which leaves the retention buffer, appduct_events, appduct_wait_for_event and appduct events carrying app events only. Everything below assumes that: an event is { name, payload, ts, seq, sessionId, alias } and kinds no longer exists on any agent- or user-facing surface. The RPC's events.subscribe keeps kinds for the daemon's own consumers, such as the wait for session_claimed behind appduct_wait_for_session.
Why
An app that posts events often makes the current surface expensive for an agent:
appduct_events filters by kind only. To find checkout_completed among two hundred app events, the agent drains all of them into its context and scans. One call can return the whole 256-event buffer, each payload up to the 256 KiB wire limit, with no cap.
appduct_wait_for_event targets one exact name and a shallow payload match; the client SDK has a predicate function instead. Two filtering models for one concept.
A since cursor that fell off the buffer returns whatever is left with no signal that anything was evicted, and a limit-truncated page gives no hint whether to page again.
appduct events has no filter at all, although events.subscribe already accepts one per connection (packages/appduct/src/daemon/daemon.ts:519).
The shipped skill never mentions appduct_events or appduct_wait_for_event, so agents do not know the tools exist or how to loop on the cursor.
Expected outcome
One filter, by name, applied in the daemon on both the drain (events.since) and the live subscription (events.subscribe), plus a payload cap and two counters, exposed the same way on every surface.
name, a whole-name, case-sensitive pattern where * matches any run of characters and a pattern without * is an exact name. cart.* matches cart.item_added; *_failed matches checkout_failed.
appduct_events({ selector?, since?, name?, limit?, payloadMaxBytes? }), limit defaulting to 50 like appduct_list_tools.
appduct_wait_for_event({ selector?, name, since?, timeoutMs?, payloadMaxBytes? }). name stays required; * waits for the next app event of any name. The match object is removed.
appduct events tail [selector] [--name <glob>] [--payload-max-bytes <n>] and appduct events since [selector] <cursor> [--name <glob>] [--payload-max-bytes <n>], both flags on both verbs.
appduct/client: events({ name?, since?, limit?, payloadMaxBytes? }) and waitForEvent(name, { since?, timeoutMs?, payloadMaxBytes? }). The match predicate is removed.
payloadMaxBytes, per event. A payload whose JSON exceeds it is replaced by payloadPreview (the first payloadMaxBytes characters of that JSON) with truncated: true and payloadBytes: n on the event; otherwise payload is untouched. It is a param on both events.since and events.subscribe, so live events (the wait tool's live phase, appduct events without --since) are capped by the daemon too. The two MCP tools default it to 4096; the RPC params, the CLI flag and the SDK have no default. The full payload comes back with since: seq - 1, limit: 1 and a larger cap.
dropped: n on every events.since result: the number of events between since and the oldest retained one, 0 when nothing was evicted. appduct_wait_for_event reports it the same way when its drain phase crossed a gap.
remaining: n on every events.since result: matching events after the returned page, so an agent pages only while it is above 0. The trailing cursor line that events since prints becomes { cursor, dropped, remaining } under --json and cursor: 12, dropped: 0, remaining: 40 otherwise.
The shipped skill teaches two patterns: after a call that triggers async work, drain with appduct_events and a name glob, keep the cursor, page while remaining > 0, treat dropped > 0 as a gap; for an expected outcome, appduct_wait_for_event with since and a long timeoutMs, which Claude Code moves to a background task after two minutes and reports as a task notification. The CLI reference table gains the new flags.
Constraints and non-goals
No change to the app-to-daemon wire protocol. Event names stay unconstrained on the wire; a literal * in a name cannot be matched exactly, and the writing-tools guidance recommends [a-z0-9_.] names.
Non-goal: payload predicates of any kind, on any surface. The SDK's predicate goes; a test that needs one loops on waitForEvent with since or filters events() itself.
Non-goal: waiting on several names in one call beyond what a glob expresses.
Non-goal: an event catalog (apps declaring the events they post). Separate design.
How we know it is done
events.since with name: "cart.*" returns only events whose name matches; name: "checkout_completed" never matches checkout_completed_v2; matching is case-sensitive.
events.subscribe with name delivers only matching live events on that connection.
appduct_events with no limit returns at most 50 events and a cursor that pages forward.
An event whose payload JSON is longer than payloadMaxBytes comes back with payloadPreview, truncated: true and payloadBytes, and no payload; a smaller one comes back unchanged. appduct_events and appduct_wait_for_event apply 4096 by default; appduct events tail --json, appduct events since --json and app.events() do not truncate unless told to.
After more than eventBufferSize app events, events.since with the original cursor reports dropped equal to the number evicted, and dropped: 0 otherwise.
remaining equals the number of matching events after the returned page and is 0 on the last page.
appduct_wait_for_event rejects match as an unknown parameter and resolves on name: "*" for the next app event.
appduct events tail --name '*_failed' --json and appduct events since 0 --name '*_failed' --json print only matching NDJSON lines, and the events since cursor line carries dropped and remaining.
app.waitForEvent("cart.*") resolves on cart.item_added; WaitForEventOptions has no match.
The shipped skill describes the drain loop and the background wait, and the CLI reference lists the new flags under events tail and events since.
Alternatives considered
A match object with dot-path keys over name and payload: rejected, one filter by name is enough and easier to teach.
Truncating in the MCP server only: rejected, the daemon param serves CLI, SDK and MCP from one implementation.
oldestSeq instead of dropped: rejected, agents would have to do the subtraction.
Lands after #93, which leaves the retention buffer,
appduct_events,appduct_wait_for_eventandappduct eventscarrying app events only. Everything below assumes that: an event is{ name, payload, ts, seq, sessionId, alias }andkindsno longer exists on any agent- or user-facing surface. The RPC'sevents.subscribekeepskindsfor the daemon's own consumers, such as the wait forsession_claimedbehindappduct_wait_for_session.Why
An app that posts events often makes the current surface expensive for an agent:
appduct_eventsfilters by kind only. To findcheckout_completedamong two hundred app events, the agent drains all of them into its context and scans. One call can return the whole 256-event buffer, each payload up to the 256 KiB wire limit, with no cap.appduct_wait_for_eventtargets one exact name and a shallow payloadmatch; the client SDK has a predicate function instead. Two filtering models for one concept.sincecursor that fell off the buffer returns whatever is left with no signal that anything was evicted, and alimit-truncated page gives no hint whether to page again.appduct eventshas no filter at all, althoughevents.subscribealready accepts one per connection (packages/appduct/src/daemon/daemon.ts:519).appduct_eventsorappduct_wait_for_event, so agents do not know the tools exist or how to loop on the cursor.Expected outcome
One filter, by name, applied in the daemon on both the drain (
events.since) and the live subscription (events.subscribe), plus a payload cap and two counters, exposed the same way on every surface.name, a whole-name, case-sensitive pattern where*matches any run of characters and a pattern without*is an exact name.cart.*matchescart.item_added;*_failedmatchescheckout_failed.appduct_events({ selector?, since?, name?, limit?, payloadMaxBytes? }),limitdefaulting to 50 likeappduct_list_tools.appduct_wait_for_event({ selector?, name, since?, timeoutMs?, payloadMaxBytes? }).namestays required;*waits for the next app event of any name. Thematchobject is removed.appduct events tail [selector] [--name <glob>] [--payload-max-bytes <n>]andappduct events since [selector] <cursor> [--name <glob>] [--payload-max-bytes <n>], both flags on both verbs.appduct/client:events({ name?, since?, limit?, payloadMaxBytes? })andwaitForEvent(name, { since?, timeoutMs?, payloadMaxBytes? }). Thematchpredicate is removed.payloadMaxBytes, per event. A payload whose JSON exceeds it is replaced bypayloadPreview(the firstpayloadMaxBytescharacters of that JSON) withtruncated: trueandpayloadBytes: non the event; otherwisepayloadis untouched. It is a param on bothevents.sinceandevents.subscribe, so live events (the wait tool's live phase,appduct eventswithout--since) are capped by the daemon too. The two MCP tools default it to 4096; the RPC params, the CLI flag and the SDK have no default. The full payload comes back withsince: seq - 1, limit: 1and a larger cap.dropped: non everyevents.sinceresult: the number of events betweensinceand the oldest retained one, 0 when nothing was evicted.appduct_wait_for_eventreports it the same way when its drain phase crossed a gap.remaining: non everyevents.sinceresult: matching events after the returned page, so an agent pages only while it is above 0. The trailing cursor line thatevents sinceprints becomes{ cursor, dropped, remaining }under--jsonandcursor: 12, dropped: 0, remaining: 40otherwise.The shipped skill teaches two patterns: after a call that triggers async work, drain with
appduct_eventsand a name glob, keep the cursor, page whileremaining > 0, treatdropped > 0as a gap; for an expected outcome,appduct_wait_for_eventwithsinceand a longtimeoutMs, which Claude Code moves to a background task after two minutes and reports as a task notification. The CLI reference table gains the new flags.Constraints and non-goals
*in a name cannot be matched exactly, and the writing-tools guidance recommends[a-z0-9_.]names.match.events.sinceloseskindsin Log Appduct's own event kinds to a file instead of showing them to agents #93;events.subscribekeeps it for internal consumers.waitForEventwithsinceor filtersevents()itself.How we know it is done
events.sincewithname: "cart.*"returns only events whose name matches;name: "checkout_completed"never matchescheckout_completed_v2; matching is case-sensitive.events.subscribewithnamedelivers only matching live events on that connection.appduct_eventswith nolimitreturns at most 50 events and a cursor that pages forward.payloadMaxBytescomes back withpayloadPreview,truncated: trueandpayloadBytes, and nopayload; a smaller one comes back unchanged.appduct_eventsandappduct_wait_for_eventapply 4096 by default;appduct events tail --json,appduct events since --jsonandapp.events()do not truncate unless told to.eventBufferSizeapp events,events.sincewith the original cursor reportsdroppedequal to the number evicted, anddropped: 0otherwise.remainingequals the number of matching events after the returned page and is 0 on the last page.appduct_wait_for_eventrejectsmatchas an unknown parameter and resolves onname: "*"for the next app event.appduct events tail --name '*_failed' --jsonandappduct events since 0 --name '*_failed' --jsonprint only matching NDJSON lines, and theevents sincecursor line carriesdroppedandremaining.app.waitForEvent("cart.*")resolves oncart.item_added;WaitForEventOptionshas nomatch.events tailandevents since.Alternatives considered
matchobject with dot-path keys over name and payload: rejected, one filter by name is enough and easier to teach.oldestSeqinstead ofdropped: rejected, agents would have to do the subtraction.Slices
Work these in order (design):