Protocol
This page describes the protocol between the efr clients and efrd. It holds a short
summary and the changelog of the protocol. The types and the frozen fixtures are in
crates/efr-protocol.
The command cargo xtask protocol-docs does not write this page yet, so you edit the
page by hand. Add a line to the changelog below for each change of the protocol.
Summary
- Transport: a Unix socket at
$XDG_RUNTIME_DIR/efr/daemon.sock. Each frame is a 4-byte big-endian length followed by one JSON object, at most 16 MiB. A phone uses the tailnet WebSocket of efrd instead, with one JSON frame per text message (see "Remote devices"). - Client frames:
{id, method, params}and{cancel: id}. Server frames:{id, item},{id, end},{id, error}, and{ack: id}on streams that ask for it. hellocomes first; both sides sendPROTOCOL_VERSION, and a mismatch fails withProtocolMismatchbefore any method runs. Additive changes do not bump the version.- The frozen fixtures in
crates/efr-protocol/fixtures/v1/are compared byte for byte. Changing one needsjust blessand a line in the changelog below. docs/protocol.schema.jsonholds every type of the protocol in the form that a code generator reads (JSON Schema draft 2020-12, a root and$defs). The phone app makes its TypeScript types from it.cargo xtask protocol-schemawrites it, andjust tidyfails when it is not current. Do not edit it by hand.
Text offsets
Every offset into text counts bytes of UTF-8. This applies to offset in
assistant_message_updated, to offset in the text and reasoning drafts, and to
text_bytes in the live member of a snapshot. An offset is not a count of characters
and not a count of UTF-16 units. A JavaScript client must compare an offset with the
UTF-8 length of its text, never with text.length. For example, größe is 5
characters, 5 UTF-16 units and 7 bytes of UTF-8; 🙂 is 1 character, 2 UTF-16 units and
4 bytes.
The index of a message counts the assistant messages of the turn that have text, from
0. A model answer without text gets no index. Drafts, assistant_message_updated and
assistant_message_completed use the same index.
Remote devices
A phone connects to the tailnet listener of efrd. The listener treats every connection
as the phone origin. Before the proof, the connection may call only hello,
device.enroll and device.prove.
The tailnet WebSocket:
- The URL is
ws://<tailnet IPv4>:<port>/, with the address and the port of the QR payload. efrd listens only on that address, and only with[remote] enabled. Thefmember of the payload gives the MagicDNS name for display only: a client never dials it. - Before efrd reads a byte, it asks tailscaled who the peer is. It closes the connection at once for this machine itself, a tagged node, a node that another tailnet shares, a node of another user and a peer that tailscaled does not know. It also closes it, with no question to tailscaled, for a peer whose address is not in 100.64.0.0/10 or fd7a:115c:a1e0::/48, such as a host behind a subnet route.
- The opening handshake is
GET /over HTTP/1.1, version 13, with no query, no subprotocol and no extension (no permessage-deflate). TheOriginheader must be absent, or exactlyhttp://<tailnet IPv4>:<port>. Every other request gets a plain HTTP error and no upgrade. - Each text message holds one JSON frame, the same frames as on the Unix socket, without the length prefix. A client message is at most 1 MiB, a server message at most 16 MiB. A binary message, a fragmented message and an unmasked client frame close the connection.
- efrd sends a ping every 25 s and closes the connection with 1001 when two pings
wait for their pong. A client that cannot see pongs, such as JavaScript, can send a
second
helloas a probe: efrd answers it withconflict, and it is not a failed proof. - A revoke, a change of the tailnet and the shutdown of efrd close the connection with 1001, and the frames that wait are dropped.
Pairing:
efr device addcallsdevice.pair_starton the Unix socket. The first item,started, holds the QR payload. Its text is anefr://pairURL (see below).- The phone scans the code, connects, and calls
hello. It stops when thedaemon_idor theprotocolof the result is not the one it expects. - The phone calls
device.enrollwith the token, its name and its public key. efrd sends the itempendingwith the facts of the phone to the terminal. - The user compares the facts and answers.
efrcallsdevice.pair_confirm. The stream then sendspairedorendedand ends. Thedevice.enrollrequest gets its result after the answer.
Rules of efrd for a pairing:
- The token has 144 random bits. efrd keeps only its SHA-256, and only in memory: a
restart of efrd ends every pairing. The token is good for one
device.enrolland for 5 minutes. At most 4 pairings are open at one time. - A wrong token is
unauthorized. While a pairing waits for a phone, efrd counts the wrong tokens. After 5 of them, the next one ends each pairing that waits for a phone withattempts. A pairing that opens while no other one waits for a phone starts the count from zero. - A public key that is not a point of the P-256 curve is
invalid, and the token stays good. - A key that a device holds that is not revoked is
conflict, and the token stays good, so the phone can make a new key and try again. - After the phone enrolled, the terminal has until the token expires to answer, but
at least 2 minutes. When the phone closes its request before the answer, the pairing
ends with
expired. - A cancel of
device.pair_start, or a closed connection, ends the pairing. A phone that waits for the answer then getsforbidden.
The key fingerprint of pending and of device.list is SHA256: and the first 8
bytes of the SHA-256 of the 65 bytes of the key, in lowercase hex. For the key 04,
then the bytes 01 to 40 (hex), it is SHA256:0ed3a6ab957ff6f5. The phone shows the
same text, so the user can compare the two.
The QR payload is efr://pair?v=1&d=<daemon_id>&h=<tailnet IPv4>&p=<port>&f=<fqdn>&t=<token>.
The members come in this order. f is absent when efrd has no MagicDNS name. Each
value is percent-encoded: every byte that is not an ASCII letter, a digit, -, .,
_ or ~ becomes % and two uppercase hex digits. The fixture pairing_qr_url.json
holds a payload and its URL.
Proof: the hello result of a remote connection has challenge and prove
(peer_node, daemon_node, daemon_tailnet). The device signs this message with its
P-256 key (ECDSA with SHA-256, the signature in DER) and sends it with device.prove.
The message is seven fields in this order:
- the domain tag
efr-device-prove-v1, challenge, the string as efrd sent it,daemon_id,protocol, in decimal, such as1,device_id,prove.peer_node,prove.daemon_tailnet.
Each field is its UTF-8 text after its length in bytes as a 4-byte big-endian number.
An id is its lowercase hyphenated form. The fixture device_prove_message.json holds a
set of inputs and the message in hex. A client in another language must give exactly
these bytes.
The answers of the tailnet listener:
- The
helloresult has emptypaths, and efrd ignores thettyof the hello: a remote device sits at no terminal of the computer. - Before the proof, every method except
hello,device.enrollanddevice.proveisunauthorized. After it, the connection holds thescopesof the result: those of the device that a phone may hold, neveradminorterminal. - A wrong signature, an unknown or revoked device, or a device of another Tailscale
user or tailnet gives
unauthorized, always with the same message. - A device that paired from another Tailscale node gives
unauthorizedwithdata{"re_pair": true, "device_id": ...}. Tailscale was installed again on the phone, and the phone must pair again. efrd sends it only after a valid signature. - After three refusals of
device.prove, efrd closes the connection. A second proof on one connection isconflict. busywithdata{"starting": true, "retry_after_secs": n}: efrd cannot check the device now. Try again afternseconds.- The Unix socket refuses
device.enrollanddevice.provewithforbidden.
What a phone may do after the proof, whatever its scopes say:
- A
prompt.sendfrom a phone runs with amodeof at mostcautious. - A
prompt.sendfrom a phone whosecontextnames attyisinvalid. approval.respondisforbiddenfor a question withlocal_only, and for everyallow_alwaysanswer. The question stays open for the user's terminal.input.respondandturn.undoareforbidden.- A phone never holds the
adminor theterminalscope.
Changelog
No protocol version has shipped yet.
- Before version 1 ships:
assistant_message_updatedcarriesoffsetanddelta, the text added since the previous update, instead oftext, the whole message so far, which made the event log grow with the square of an answer's length. - Before version 1 ships: answers to a running command that waits for input. New event
kind
tool_call_input_changedwithinput(none,visibleorhidden), new methodinput.respond(scopeterminal, no command id) whosetextis a plain string, and a new optionalanswers_inputflag onconversation.subscribe, false when absent. Theconversation_subscribe_params.jsonfixture now sets it. - Before version 1 ships: turn settings. New wire enum
mode(manual,cautious,auto).prompt.sendand theprompt_queuedevent carry an optionalsettingsobject with an optionalmode,modelandeffort, where the effort is a plain string; absent means none. Theturn_startedevent and theprompt.sendresult carry an optionalsettingsobject with the effectivemode,modeland optionaleffort, and anoverriddenobject whosemode,modelandeffortflags say which values came from the prompt; absent means not recorded, as in turns from before. The fixturesprompt_send_params.json,prompt_send_result.json,events/prompt_queued.jsonandevents/turn_started.jsonnow set them, and the newmodes.jsonfreezes every mode. - Before version 1 ships: new method
models.list(scoperead, no params) whose result listsmodels, each with itsid, itsefforts(plain strings), an optionaldefault_effort, adefaultflag for the model that a turn uses when the prompt names none, and itssource(builtinorconfig). New fixturesmodels_list_params.jsonandmodels_list_result.json. - Before version 1 ships: new method
admin.config_reload(scopeadmin, no params) whose result says whether the config wasapplied, and carries an optionalerror(message, optionalline,columnand dottedkey) and therestart_neededkeys, absent when empty. New fixturesadmin_config_reload_params.jsonandadmin_config_reload_result.json. - Before version 1 ships: the
admin.statusresult gains an optionalrootsobject (config,data,stateandruntime, each apathand asource:dir_variable,efr_home,xdgorrun_user) and an optionalconfigobject (thepathofconfig.toml, whether itexists, an optionalsymlink_target, the optionalreload_errorof the last reload in the form of theadmin.config_reloaderror, and therestart_neededkeys, absent when empty). Absent means not reported, as from an earlier daemon. Theadmin_status_result.jsonfixture now sets both. - Before version 1 ships: the daemon applies turn settings and answers
models.list. Theturn_startedevent and theprompt.sendresult now always carrysettings; a turn from a remote origin records amodeof at mostcautious. Aprompt.sendwhosesettings.modelis not inmodels.list, or whose effort (its own or the config's) is not one of the model'sefforts, fails withinvalidanddata{setting, value, choices}, plusmodelfor an effort; a turn whose settings no longer fit when it starts ends withturn_failedand the same error body. No wire type changed. - Before version 1 ships: the daemon answers
admin.config_reload(it reloads at once and reports the outcome), and theadmin.statusresult now always carriesrootsandconfig. No wire type changed. - Before version 1 ships: a restart no longer holds queued prompts. At the next start
the daemon records each prompt that waited, and each one an earlier daemon held, as
not run with
turn_cancelled(noturn_startedbefore it), and writes a notice to its terminal that names it and says to send it again; nothing runs it later.prompt_heldstays in the protocol for older logs, and no daemon writes it any more. No wire type changed. - Before version 1 ships: answers at the edges. The
tool_call_input_changedevent gains an optionallooks_secretflag, false when absent: avisiblewait whose prompt line looks like a password prompt while the terminal is not in line mode, as behind a relay such assudo's own pty,sshordocker exec; a client hides what the user types and still sends a visible answer.input.respondgains an optionalmanualflag, false when absent: an answer that the user chose to type for a command that reported no wait, which the daemon takes while that call's command runs and a job of it holds the terminal, without the check against a reported wait and its kind. An answer after the call'stool_call_completedgetsnot_found. The fixturesevents/tool_call_input_changed.json(now avisiblewait that looks secret) andinput_respond_params.json(now a manual visible answer) set the new flags. - Before version 1 ships: the project registry. New wire type
project_info(id,rootand an optionalname). New methodprojects.list(scoperead, no params) whose result carries the registryfileand itsprojects. New methodsadmin.project_add(scopeadmin; paramspath, an optionalnameand agit_rootflag, false when absent) andadmin.project_remove(scopeadmin; paramspath), whose results carry theproject, thefilethat was written and thereloadthat followed, in the form of theadmin.config_reloadresult. A broken file or project isinvalid, a second registration or a file that changed during the write isconflict, and a root that no project has isnot_found. New fixturesprojects_list_params.json,projects_list_result.json,admin_project_add_params.json,admin_project_add_result.json,admin_project_remove_params.jsonandadmin_project_remove_result.json. - Before version 1 ships: the
tool_call_startedevent gains an optionalmanual_inputflag, false when absent: the call takes an input that the user chooses to type while it reports no wait (input.respondwithmanual). A client offers such an input only for a call with the flag. Ashellcall has it unless its command types into a shell that reads command lines (nested_shell, or a command that starts a shell or a REPL), and the daemon now refuses a manual answer for such a call withconflict, and for a job in the foreground that its last look at the command did not see. Theevents/tool_call_started.jsonfixture now sets the flag. - Before version 1 ships: the
approval_requestedevent gains an optionalinteractiveflag, false when absent: the call may wait for input at the terminal, such as asudopassword. A client whose user approves such a call with a key may keep the keys typed while it runs, for the answer it asks for. Theevents/approval_requested.jsonfixture now sets the flag. - Before version 1 ships: the wire types of the
autosandbox (phase 1). New wire typeslaunch(direct,containedwith optionalgrants,unsandboxed),grant(write,host,open_network,socket,bus,device,unmask),exit_kind(17 kinds, frozen inexit_kinds.json),exit_source,needs,exit_info,mode_fallback,sandbox_status,sandbox_check,sandbox_paths,sandbox_summary,blocked,surface_change,reported_file,question_id, and the classifier'sexit_recordwith its facts andjudgement.turn_startedsettings gain an optionalfallback(askedandreason).tool_call_startedgains an optionallaunch,tool_call_completedan optionalsandboxsummary (names only), andapproval_requestedan optionalexit. Absent means not reported, as before. New event kindsexit_requested,exit_judged,sandbox_surface_changed,surface_question_requested,surface_question_answered,turn_surface_reportandsandbox_unavailable. New methodssandbox.explain(scoperead; paramspathand an optionalcwd),sandbox.surface_respond(scopeapprove, a write with acommand_id; paramsconversation_id,question_idandkeep) andadmin.sandbox_check(scopeadmin, no params). Theadmin.statusresult gains an optionalsandboxstatus and optionalsandbox_paths. The daemon answers the three new methods from phase 1;sandbox.explainneeds only thereadscope. The fixturesadmin_status_result.json,events/turn_started.json,events/tool_call_started.json,events/tool_call_completed.jsonandevents/approval_requested.jsonnow set the new members;exit_kinds.json,grants.jsonandlaunches.jsonfreeze the new closed sets. - Before version 1 ships:
sandbox_summarygains an optionalsurvivorslist, empty when absent: the programs of an approved exit's descendants that efr could not end, such as a process thatsudoleft running as root. The daemon then closes the conversation's hidden shell, and the next call starts a new one. Theevents/tool_call_completed.jsonfixture now sets the list. - Before version 1 ships:
sandbox_summarygains an optionalsetup_error: why the sandbox could not start, such as bubblewrap's setup message. The command did not run, and the daemon checks the sandbox again. A client shows it on its own line. Theevents/tool_call_completed.jsonfixture now sets it. - Before version 1 ships: the
tool_call_completedevent gains an optionalrefusal: why efr refused the call before it ran, in a few words, such asefr's config (floor). It is absent for a call that ran and for one that the user denied. A client shows it in place of a plain "failed". Theevents/tool_call_completed.jsonfixture now sets it. - Version 1, additive: drafts.
conversation.subscribetakes an optionaldraftsflag, false when absent. With it, the stream also carries items of the new kinddraft(turn_id,after_seq, and adraftobject): what a running turn got from the model before the event log has it. Thedraftobject istext(index,offset,delta, as inassistant_message_updated),reasoning(offsetin the reasoning of the turn,delta, an optionaltitle) ortool_input(call, the position of the call in the answer,tool, andbytesof input so far).after_seqis the last event that the turn recorded before the draft. A draft has no sequence number of its own, the daemon never stores it, drops it when the queue of the subscriber is full (a later entry,drafts_coalesced, replaces this: the daemon merges the drafts that wait), and never closes a subscription for it. A client without the flag gets no drafts. Theconversation_subscribe_params.jsonfixture now sets the flag; the newconversation_subscribe_item_draft.jsonanddraft_parts.jsonfreeze the item and every kind of part. - Version 1, additive: what the agent changed in files. New types
file_change(pathas a client shows it: relative to the turn's project root, under$SCRATCH/, under~/or absolute;kind, one ofadded,modified,deletedandrenamed; an optionalfromfor a rename;addedandremovedline counts;binary, false when absent) andfile_changes(files, at most 50 and sorted by path,more, the files left out, and theaddedandremovedtotals). Thetool_call_completedevent gains an optionalchanges(a file tool's write, or what changed while ashellcall ran in the turn's project,$SCRATCHand, inauto, the projects that the line names, also a file that another conversation or the user changed then) and an optionaldiff(the unified diff of a file tool's write, at most 2000 lines and then... N more lines). Theturn_completedevent gains an optionalchanges: the turn's first snapshot against its last, with the same caveat. New methodconversation.diff(scoperead, no command id) with an optionalconversation_id(absent: the conversation of the named turn, else the active conversation of the hello's terminal), an optionalturn_id(absent: the newest turn of the conversation that started and ended, not a cancelled one) and astatflag, false when absent; its result has theturn_id, thechangesand, withoutstat, the unifieddiff, at most 20000 lines, where a changed ignored file such as.envhas only a line<path>: ignored file, content not shown. The fixturesevents/tool_call_completed.jsonandevents/turn_completed.jsonnow set the new members;conversation_diff_params.jsonandconversation_diff_result.jsonare new. - Version 1, additive: steer, queue and withdraw from the input row of a turn.
turn.steertakes an optionalif_lateobject, absent by default:{kind: "queue"}with an optionalcontext,last_commandandsettings, as inprompt.send. A steer is late when the turn makes no more model calls: the model answered without a tool call, an interrupt was requested,turn_idnames a turn that does not run, or no turn runs. Withoutif_latea late steer getsconflict, as before. With it, the daemon recordsprompt_queued(with the steer'scommand_id) instead ofturn_steered, and the result hasqueued: true, the newturn_idand theseqofprompt_queued. A late steer is never recorded asturn_steered. Theturn.steerresult gainsqueued, false when absent. New event kindsteering_delivered(turn_idand thesteers, the seqs of theturn_steeredevents that a model call read). New methodprompt.withdraw(scopeoperate, a write with acommand_id; paramsconversation_idand atarget:{kind: "turn", turn_id}or{kind: "newest_from_tty", tty}) whose result has thewithdrawnprompt (turn_id,seqof the event,text); a prompt that started getsconflict, and a target without a waiting prompt getsnot_found. New event kindprompt_withdrawn(turn_id,origin), the last event of a turn that never started.turn.interrupttakes optionalresend_steers(seqs of unreadturn_steeredevents to send again as one prompt that runs next),resend_as({kind: "queue"}with thecontext,last_commandandsettingsof that prompt, as inif_late; absent: those of the interrupted turn),withdraw_steers(seqs of unreadturn_steeredevents to take back) andwithdraw(turns of queued prompts to withdraw), all done in the step that recordsturn_interrupt_requested; its result gains an optionalresent(turn_id,seq,steers),withdrawnandwithdrawn_steers(seq,text), absent when empty. New event kindsteering_withdrawn(turn_id,steers,origin): steers taken back that no model call read. Theprompt_queuedevent gainssteers, absent when empty: the steers whose text it carries. The fixturesturn_steer_params.json,turn_steer_result.json,turn_interrupt_params.json,turn_interrupt_result.jsonandevents/prompt_queued.jsonnow set the new members;prompt_withdraw_params.json,prompt_withdraw_result.json,events/steering_delivered.json,events/steering_withdrawn.json,events/prompt_withdrawn.jsonandwithdraw_targets.jsonare new. - Version 1, additive: freeform tools, whose input is plain text, such as the patch
of
apply_patch. Thetool_call_startedevent gains an optionalfreeformflag, false when absent. With the flag,inputis a JSON string that holds the text as the model wrote it. Without it,inputis the JSON that the model wrote, as before (or a JSON string with arguments that were not JSON). A client shows a freeform input as text. Thetool_inputdraft part counts the bytes of that text inbytes. The new fixtureevents/tool_call_started_freeform.jsonsets the flag;events/tool_call_started.jsondoes not change. - Version 1, additive: a file tool's call can change several files. The
changesoftool_call_completedlist every file of anapply_patchcall, withdeletedfor a deleted file andrenamed(andfrom) for a moved one. Itsdiffholds the diff of each file in the order the call changed them, one after the other, each with its two header lines (+++ /dev/nullfor a deleted file), together at most 2000 lines. No member and no fixture changes. - Version 1, additive: the context window and its compaction.
usagegains optionalcached_input_tokensandreasoning_tokens(sums over the turn's model calls, parts ofinput_tokensandoutput_tokens) andcontext_tokens(the last model call's input plus its output), each absent when zero.turn_failedandturn_interruptedgain an optionalusage.turn_completed,turn_failedandturn_interruptedgain an optionalcontext(tokens,limit,window): how full the model's context was at the end, wherelimitis the point that a client shows as 100%. A model ofmodels.listgains an optionalcontext_window. New draft partscontext(the same three members, before and after each model call) andcompacting(trigger). New event kindconversation_compacted(compaction_id, optionalturn_id,trigger:auto,manualoroverflow, optionalfocus,model,window,limit,tokens_before,tokens_after,through_turn, optionalthrough_message,kept_turns, optionalpruned_outputs,pruned_tokens,omitted_turnsandomitted_messages(what the summary never saw, absent when zero),summaryandusage). New methodconversation.compact(scopeoperate, a command id,conversation_id, optionalfocus) whose result has theseqof the event and thecompaction. The fixturesdraft_parts.json,models_list_result.json,events/turn_completed.json,events/turn_failed.jsonandevents/turn_interrupted.jsonnow set the new members;conversation_compact_params.json,conversation_compact_result.jsonandevents/conversation_compacted.jsonare new. - Version 1, additive: the model catalog comes from the provider's backend. A model of
models.listgains an optionalmax_context_window(the largest window that[openai] modelscan set) andprefer_websockets(false when absent), and itscontext_windowis the window that efrd uses. The sourcebuiltinnow means the provider's catalog. The result ofmodels.listand ofadmin.statusgains an optionalcatalog(origin:backend,cacheorbuiltin, and an optionalfetched_at). The fixturesmodels_list_result.jsonandadmin_status_result.jsonnow set the new members. - Version 1, additive: cache writes.
usagegains optionalcache_write_tokens(the part ofinput_tokensthat the provider wrote to its prompt cache) andcache_write_1h_tokens(the part ofcache_write_tokenswritten with a time to live of one hour), both sums over the turn's model calls and absent when zero. For every provider,cached_input_tokensand the two write counts are parts ofinput_tokens. The fixturesevents/turn_completed.json,events/conversation_compacted.jsonandconversation_compact_result.jsonnow set them. - Version 1, additive: the stored fresh context block.
Compaction(inconversation_compactedand in the result ofconversation.compact) gains an optionalfresh: the fresh context block that every request after a compaction with a summary sends before the summary, as efrd read it when the compaction ended. It is absent for a compaction without a summary and for one from before the field. The fixturesevents/conversation_compacted.jsonandconversation_compact_result.jsonnow set it. - Version 1, additive: logins with an API key. New method
admin.login_api_key(scopeadmin, unary, no command id) withprovider(openai-apioranthropic-api),key(a plain string that the daemon never logs) andcheck(true when absent), whose result has theprovider, thekey_hint(the known prefix and the last four characters of the key, such assk-ant-...a1b2),checked(false when the check was skipped) andactive(true when the provider is the one of new conversations). New methodadmin.logout(scopeadmin, unary, no command id) withprovider, whose result has theproviderandlogged_out(false when nothing was stored). A provider of theadmin.statusresult gainsactive(false when absent), an optionallogin(subscriptionorapi_key) and an optionalkey_hint, and the daemon now listsanthropic-apitoo. New fixturesadmin_login_api_key_params.json,admin_login_api_key_result.json,admin_logout_params.jsonandadmin_logout_result.json;admin_status_result.jsonnow sets the new members. - Version 1, additive: the catalog of each provider. The
catalogof themodels.listandadmin.statusresults gains an optionalprovider, the provider whose catalog it is, absent from an earlier daemon.origingainsmissing: no list yet, because no fetch worked, no cache is on disk and the provider has no list built into efr (anthropic-api). The fixturesmodels_list_result.jsonandadmin_status_result.jsonnow setprovider. - Version 1, additive: refused keys and logouts. A provider of the
admin.statusresult gains an optionalkey_refused_at: when the provider last refused its stored API key (a 401 on a model call or on the fetch of the model list). It is absent while the key works, after a new login and from an earlier daemon. The result ofadmin.logoutgainsactive(false when absent): true when the provider is the one of new conversations, whose turns fail until a new login. New event kindlogout_completedwith theprovideronly; it belongs to no conversation, likelogin_completed. New fixtureevents/logout_completed.json;admin_status_result.jsonandadmin_logout_result.jsonnow set the new members. - Version 1, additive: the classifier, presence, cache deletes and floor file backups of
the auto mode (phase A). New event kinds
exit_review_started(turn_id,call_id,kinds),classifier_paused(turn_id,reason:in_a_roworper_turn,denials),presence_changed(conversation_id,presence:presentoraway,source:input,userortimeout; it belongs to no turn),cache_delete_applied(turn_id,call_id,paths,freed_bytes) andhome_file_changed(turn_id,call_id,path,floor, optionalcoveredandbackup).approval_expiredgainsaway(false when absent).exit_judgedgains an optionalchain_step(classifier_model,turn_modeloruser) andskipped(the models that the chain skipped, absent when none), and itsjudgecan bepresence_expired. In the exit record,actiongains an optionaledit(the payload of a file tool edit of a floor file, at most 8 KiB), a target gainsbackup_before_writeandmasked(a path that the sandbox hides, such as a credential store; both false when absent),factsgainsdenials_in_a_rowanddenials_this_turn(0 when absent), and a target'sclasscan becache,build_outputortrash. An exit'ssourcecan becache_delete: a delete in a real cache that a contained call made. Thesandboxoftool_call_completedgainscache_deletesandcache_deletes_more, absent when none. New fixturesevents/exit_review_started.json,events/classifier_paused.json,events/presence_changed.json,events/cache_delete_applied.json,events/home_file_changed.jsonandevents/exit_requested_floor_edit.json;events/approval_expired.json,events/exit_judged.json,events/exit_requested.jsonandevents/tool_call_completed.jsonnow set the new members. - Version 1, additive: presence. New method
presence.set(scopeoperate, unary, with a command id) with an optionalconversation_id(absent: each conversation that had input since the daemon started) andpresence(presentoraway), whose result has thepresence, thechangedconversations (absent when none changed) and theseqof the lastpresence_changed(absent when none changed). New fixturespresence_set_params.jsonandpresence_set_result.json. - Version 1, additive: desktop grants. A
grantcan bedesktopwith adesktop(wayland,x11,hyprland,sway,i3orniri): a bind of the one control socket of the desktop of the prompt's terminal. Ashell_contextgains an optionaldesktopwith the optional memberswayland_display,display,hyprland_instance_signature,swaysock,i3sockandniri_socket, the values of these variables in the user's shell, andhas_xauthority(true when the shell has$XAUTHORITY, never its path; false when absent); absent when the shell has none of the six variables.grants.jsonnow holds a desktop grant of each desktop, andprompt_send_params.json,turn_steer_params.json,turn_interrupt_params.jsonandevents/prompt_queued.jsonnow setdesktop. - Version 1, additive: why a turn stopped.
turn_completedgains an optionalstopwith areason(end_turn,max_tokens,refusal,content_filterorpause_turn) andempty(false when absent): the last model call gave no text and no tool call. It is absent for a normal answer and in turns recorded before it.events/turn_completed.jsonnow sets it. - Version 1, additive: labels of exits and changed files. In the exit record,
factsgainsoutside_commands(the commands of anoutsideexit that run outside the sandbox, absent when none) anddesktop_read(a desktop command that only reads the desktop's state or reloads its config; false when absent).home_file_changedgains an optionalreal: the real file whenpathreaches it through a link.events/exit_requested.jsonandevents/home_file_changed.jsonnow set the new members. - Version 1, additive: undo of a turn (phase B). New method
turn.undo(scopeoperate, unary, with a command id) with an optionalconversation_idandturn_idand the flagsdry_run,force,redoandlist(false when absent). Its result has theconversation_id, the optionalturn_id,title,undo_idandtrash, the flagsapplied,stopped(a write failed after the undo changed files) andresumed, and the listsentries(each with anaction:restore,trash,delete,skip,not_coveredorcommand, apathand an optionalreason),entries_more,refs(each withroot,nameand the optionalfrom,to,appliedandreason),notesandturns(withlist: each withturn_idand the optionaltitle,auto,undone,stoppedandended), each absent when empty. A refusal is aconflicterror whosedataholds the refusal asrefused({"kind": "off"}until undo comes);undo_refusals.jsonfreezes its forms. New event kindssnapshot_taken(turn_id,root,phase:preorpost,commit,files,skipped,ms) andundo_applied(conversation_id,undone_turn_id,undo_id,redo,origin,restored,trashed,deleted,conflicts,not_covered, optionalrefsandtrash, andstopped, false when absent; it belongs to no turn).turn_completed,turn_failedandturn_interruptedgain an optionalundo(the optionalundoable,rootswithshown,filesandrefs(0 when absent),home_files,not_coveredandnot_covered_more), andturn_failedandturn_interruptedgain an optionalchanges. From phase B,home_file_changedhascoveredand nobackup. New fixturesturn_undo_params.json,turn_undo_result.json,events/snapshot_taken.json,events/undo_applied.json,undo_actions.json,undo_reasons.jsonandundo_refusals.json;events/turn_completed.json,events/turn_failed.jsonandevents/turn_interrupted.jsonnow set the new members. - Version 1, additive: background jobs (phase C). New wire types:
job_id(a stringj1,j2, ... per conversation; any other string is refused),job_state(running,exited,stopped,timed_out,failed),job_stop_by(model,user,time_limit,daemon_stop,daemon_restart,guard),notice_kind(exited,watch) andheld_reason(wake_limit,wake_turn_job,daemon_restart,wakes_off, andnot_auto, which only older builds record). A reader takes a value of these enums that it does not know asunknown. Ajob_infohasid,conversation_id,turn_id,call_id,command,launch,state,started_at,ends_by, the optionalended_at,exit_code,stopped_byandwatch(pattern,max_wakes),output,output_bytesandwakes(0 when absent). New methodjobs.list(scoperead, unary, an optionalconversation_id; the result'sjobsare newest first, at most 200, absent when none) andjobs.stop(scopeoperate, unary, with a command id, an optionalconversation_idand ajob_id; the result has thejob). New event kindsjob_started(turn_id,call_id,job,launcher_pid,launcher_start),job_watch_matched(job_id,line,wakes_left),job_ended(job_id,state, the optionalexit_codeandstopped_by,output_bytes,ran_secs,tail, and the optionalsurface_changes,quarantined,survivors,launcher_lostandoutput_truncated),job_notice(conversation_id,turn_id,job_id,notice_kind,woke,source_seq,not_user_input, always true, andtext) andjob_notice_held(job_id,source_seq,reason).job_startedandjob_noticebelong to their turn; the other three belong to no turn. In the exit record,actiongainsbackground,watchandwake_turn(false when absent) andmax_minutes(absent when the call starts no job). New fixturesjobs_list_params.json,jobs_list_result.json,jobs_stop_params.json,jobs_stop_result.json, the fiveevents/job_*.json,job_states.json,job_stop_causes.json,held_reasons.jsonandnotice_kinds.json;events/exit_requested.jsonnow sets the new members. - Version 1, additive: the network view (phase D1). New grant kind
net_view(no other member): the call keeps the host's network namespace but can open only Unix and netlink sockets. A reader takes a grant kind that it does not know asunknown; efrd never sends one, and the launcher refuses a call that holds one. The sandbox status gainsnetview(false when absent): true when the probe's network view self-test passed. It also gainsnetview_off(false when absent, sent only when true): efrd sets it inadmin.statusandadmin.sandbox_checkwhen the user'snetwork.netviewis false.grants.jsonnow holdsnet_viewandunknown,launches.jsona launch withnet_view, andadmin_status_result.jsonandadmin_sandbox_check_result.jsonsetnetview. - Version 1, additive: the proxy's refusals and status (phase D2).
block_reasongainsip_literal,not_tls,bad_request,limitandunknown; a reader from this version on takes a reason that it does not know asunknown. Anefrfrom before this version cannot readip_literal,not_tls,bad_requestorlimit, and fails on the frame that holds one. Ablockedentry gainscount(1 when absent, sent only when it is not 1): how many connections the proxy refused for that host, port and reason.sandbox_summarygainsblocked_more(0 when absent, sent only when it is not 0), the refusals thatblockedleaves out (it lists at most 32), andproxied(false when absent, sent only when true): the call ran with a proxy plan. Thejob_endedevent gains the sameblockedlist andblocked_more, absent when empty. A host fact of the exit record gainsproject_remote(false when absent): the host of a remote of a pinned project git dir. The sandbox status gainsproxy(false when absent): true when the probe's proxy self-test passed. The newblock_reasons.jsonfreezes every reason;events/tool_call_completed.json,events/job_ended.json,events/exit_requested.json,admin_status_result.jsonandadmin_sandbox_check_result.jsonnow set the new members. - Version 1, additive: the bus filter and always-allow (phase E).
approval_decisiongainsallow_always: run the call and save the rule that the question offered. Anefror a daemon from before this version cannot read it; a turn that does not know it denies the call. The exit info ofapproval_requestedgainsalways(absent when no rule may cover the call): the rule in words; a client offersallow_alwaysonly when it is set. New event kindalways_rule_saved(turn_id,call_id,outcome), which belongs to its turn;outcomeis an object tagged bykind:saved(index,rule),not_saved(reason:changed,read_only,load_check,io,reload_timeout) orunknown, and a reader takes a state or a reason that it does not know asunknown.approval_resolveddoes not change. New grant kindbus_filter(bus): the call's own filtered socket of that bus, a view likenet_view; a reader that does not know it takes it asunknown, and a launcher that has no plan step for it refuses the call. The sandbox status gainsbus_proxy(false when absent): true when efrd runs the filtered bus proxy, andbus_proxy_off(absent when the filter is on):config,no_program,self_test,old_versionorunknown.sandbox_summaryand thejob_endedevent gainbus_refused(absent when empty, at most 16 entries ofbus,name,member,pathandcount, 1 when absent) andbus_refused_more(0 when absent). The new fixturesapproval_decisions.json,always_saved.json,not_saved_reasons.json,bus_proxy_off_reasons.jsonandevents/always_rule_saved.jsonfreeze them;grants.json,launches.json,events/approval_requested.json,events/tool_call_completed.json,events/job_ended.json,admin_status_result.jsonandadmin_sandbox_check_result.jsonnow set the new members. - Version 1, additive: full mode. The wire enum
modegainsfull, afterauto: every call runs as the user with no sandbox and no question, and efr still reads each line for a small set of floors. Anefrfrom before this version cannot read aturn_startedor aprompt.sendresult withmode: full;efrandefrdship together. New wire enumfull_refusal(root,snapshot_off,undo_off,no_zsh): why the daemon refuses full mode althoughpermissions.allow_fullis true. Thejudgeofexit_judgedgainsfull: full mode let the exit run with no review and no question, or a question of full mode expired.approval_requestedgainsexpires_in_secs(absent when the question has no fixed limit), such as 120 for a delete that full mode guards; the target and the reason stay insummary. Theadmin.statusresult gainsfull_mode(absent when not reported), withallowed(permissions.allow_full) andrefused(afull_refusal, absent when the daemon does not refuse it). Itsconfigobject gainsheld(absent when nothing is held): a change ofconfig.tomlthat loosens permissions and came while full mode ran, withloosens, the config keys that loosen. A turn of a full conversation that a job notice starts records the fallback reasona turn that a job notice starts runs with at most auto. The new fixturesjudge_kinds.json,full_refusals.jsonandevents/exit_judged_full.jsonfreeze them;modes.json,events/approval_requested.jsonandadmin_status_result.jsonnow set the new members. - Before version 1 ships: each
turnsentry of aturn.undoresult gainsfull(false when absent): the turn ran in full mode, andautois true too then.turn_undo_result.jsonnow sets it. - Version 1, additive: the recent commands of a terminal.
prompt.sendand thequeueobject ofturn.steerif_lateandturn.interruptresend_asgainrecent_commands(absent when empty): the terminal's recent command lines, oldest first, each withcommandand the optionalstatus,cwd,ended_atandduration_ms. Likelast_command, they reach only the turn's preamble: never ashell_context, an event or the store.prompt_send_params.json,turn_steer_params.jsonandturn_interrupt_params.jsonnow set them. - Version 1, additive:
job_stop_bygainsfull_off, beforeunknown: a call of full mode started the job, and a config reload turned full mode off, so efrd stopped it. Its notice starts no turn of its own. A reader from before reads it asunknown.job_stop_causes.jsonnow lists it. - Version 1, additive: the
factsof an exit record gaingit_code(absent when efr did not use it): for anoutsideoruploadexit of a git or gh line, the git code that the line runs outside the sandbox, when it made the exit one that only the user may approve. It holdschecked(false when efr could not check it) anditems(absent when empty), each withrepo,item(such ashooks/pre-pushorcore.sshcommand),fileandwritable(a contained call can write the file). The record keeps only the writable items, and anitemnever holds the user name or password of a URL in a config key.events/exit_requested.jsonnow sets it. - Version 1, additive: new method
conversation.context(scoperead, no command id) withconversation_idand an optionalmodel: what fills the model's context of a conversation. Its result has the model and its limits (window,window_known,trigger,hard_cap,auto), the last real countcounted(absent when no turn has one since the newest compaction), theestimateof the next request and itsparts(one per kind of the new wire enumcontext_part_kind), thelargestitems, thecompactionsand thecacheuse of the conversation's model calls. It holds only sizes, counts and labels, never the text of a message. New fixturesconversation_context_params.json,conversation_context_result.jsonandcontext_part_kinds.json. - Version 1, additive:
block_reasongainsdenied, beforeunknown: the proxy refused a host that the deny list holds, or that efrd denies for the call because a file or a credential helper can give the call a token for it. Such hosts werenot_allowedbefore. No host grant opens them. A reader from before reads it asunknown.block_reasons.jsonnow lists it. - Version 1, additive: routing by scope.
conversation_createdgainsroute(absent in conversations from before, and for a first prompt without a working directory): the scope key of its first prompt, the new wire enumroute_scope({"kind": "project", "value": "<project id>"}or{"kind": "outside"}; a kind that a reader does not know, with or without a value, reads asunknown). New event kindterminal_attachedwithtty,route,shell_pid(absent when unknown) andhow(picked_up,resumed, orunknown): the conversation became the conversation of that terminal's slot for that key. A reader from before reads it asunknown. The result ofprompt.sendgainsroute(absent for a prompt with no terminal or with a conversation id): thescopeof the prompt (scope,nameof a project,dir: the project's root or the prompt's working directory), theswitch(absent when the conversation did not change, at a terminal's first prompt and at,new;backandpicked_upwithtitleandupdated_at,newwithfromand areason:scope,idlewithhours, ormovedwithttyandtitle) andhint(a git repository that is not a project; absent for now). The params ofconversations.listgaintty: only that terminal's conversations, its current one first, in one page; a daemon from before ignores it. New fixturesevents/terminal_attached.json,route_scopes.json,route_switches.jsonandattach_hows.json;events/conversation_created.json,prompt_send_result.jsonandconversations_list_params.jsonnow set the new members. - Version 1, additive:
exit_judgedgainsreasked(false when absent): the classifier model wrote no real reason (empty, too short or a placeholder), and the judge asked it once more.events/exit_judged.jsonnow sets it. - Version 1, additive: the result of
admin.config_reloadgainswarnings(absent when empty): what the reload changed that the user should know, one sentence each, such as a[model] nameof another company, an always rule that a floor now disables, or a bus filter that is now off. Each comes once, from the reload that changed it; a held change stays inerror.admin_config_reload_result.jsonnow sets it. - Version 1, additive: each
providersentry of theadmin.statusresult gainskey_checked_at(absent when unknown): for an API key, when the provider last answered a request with the key since the daemon started, refused or not, or when a login checked it.admin_status_result.jsonnow sets it. - Version 1, additive: resuming a conversation (
,resume,efr resume). New methodconversations.recent(scoperead, no command id) with an optionaltty,pwdandlimit: the recent conversations, the ones of the scope key ofpwdfirst, each part newest first. Its result has thescopeofpwd(absent without one), the terminal'scurrentconversation (absent without attyor when it holds none) andconversations, each with itssummary,route,project(the name; absent outside projects and for a removed project),turns(prompt turns only),open_in(another open terminal that holds it; absent when none) andhere(false when absent). New methodconversation.resume(scopeoperate, with a command id) withconversation_id, the terminal'scontextandtake(false when absent): the conversation becomes the conversation of the terminal's slot for its own scope key, and efrd recordsterminal_attachedwithhowresumed. Its result isresumed(conversation_id,scope,title,updated_at,shell_dir,hereandcontinues_here, each flag false when absent) orheld(ttyandtitle: another open terminal holds it andtakewas not set, so nothing moved); a kind that a reader does not know reads asunknown. A daemon from before refuses both as frames that are not valid. New fixturesconversations_recent_params.json,conversations_recent_result.json,conversation_resume_params.json,conversation_resume_result.jsonandconversation_resume_result_held.json. - Version 1, additive: port forwards of contained jobs. New event kind
job_port_forwarded(job_id,listen, the address that the job listens on as text such as127.0.0.1or::1,port,outcome, andby_jobandreason, each absent when not set). It belongs to the conversation, not to a turn. The new wire enumoutcomeisforwarded,in_use,refused,limitorstopped; a value that a reader does not know reads asunknown.by_jobnames the job of the same conversation that holds the port of anin_useoutcome;reasonis a short text forrefusedandlimit, such asa service port.JobInfogainsforwards(absent when empty), each withlistenandport, sojobs.listandjobs.stopshow the ports that efr forwards for a job. New fixturesevents/job_port_forwarded.jsonandforward_outcomes.json;jobs_list_result.jsonandjobs_stop_result.jsonnow setforwards. - Version 1, additive: a command left running in the hidden shell. Three new event
kinds of the conversation, not of a turn:
shell_command_detached(call_id,command, the first 200 characters of the line,since,contained,input, andlooks_secret,promptandapproved_by, each absent when not set): the command of ashellcall still runs after its call answered the model, and the shell takes no other call until it ends;shell_command_input_changed(call_id,input,contained, andlooks_secretandprompt, each absent when not set): that command began or stopped waiting for input;shell_command_ended(call_id,how, andexit_code, absent when not known).promptis the row of the screen that holds the cursor, at most 200 characters. The new wire enumhowisended,stopped,limit,closed,shell_exitedormoved, and the new wire enumapproved_byisyou,your_rule,reviewerorfull_mode; a value that a reader does not know reads asunknown.tool_call_completedgainsleft_running(false when absent): true when the call answered while its command still runs. The detach event andtool_call_completedcome in either order; a client links them bycall_id. New fixturesevents/shell_command_detached.json,events/shell_command_input_changed.json,events/shell_command_ended.json,shell_command_ends.jsonandapproved_bys.json;events/tool_call_completed.jsonnow setsleft_running. - Version 1, additive: the hidden shell's state and a stop of a command left running.
New method
shell.interrupt(scopeterminal, with a command id) withconversation_id,call_idandclose(false when absent): SIGINT to the group of the command that the call left running, or withclosea close of the hidden shell. Its result is empty. New methodshell.status(scoperead, no command id) withconversation_id. Its result hasbusy(absent when the shell is free):call_id,command,since,contained,input, andlooks_secret,promptandapproved_by, each absent when not set. A daemon from before refuses both as frames that are not valid. New fixturesshell_interrupt_params.json,shell_interrupt_result.json,shell_status_params.jsonandshell_status_result.json. - Version 1, additive: the move of a running
shellcall to a background job (Ctrl+B inefr). New methodcall.background(scopeterminal, with a command id) withconversation_id,call_idandconfirm(absent when not set). Withoutconfirm, a call in the sandbox moves at once, and a call outside the sandbox does not move: the result is apreview(token,command,max_minutes, andexits, absent when empty), and the client asks the user. A yes sends the method again with the token inconfirm. The result ismoved(job_id) orpreview; a kind that a reader does not know reads asunknown. A refusal isinvalidwith a short reason.JobInfogainsmoved_from(absent for a job that the model started):call_id,turn_id,after_secs,exits(absent when empty) andlauncher(true when the call ran through efr's sandbox launcher; absent when false), andwaits_input(true while the command of a running moved job waits for input that is not a secret; absent when false). A daemon from before refuses the method as a frame that is not valid. New fixturescall_background_params.json,call_background_result.json,call_background_result_preview.jsonandevents/job_started_moved.json. - Version 1, additive: the search of conversations. New method
conversations.search(scoperead, no command id) withqueryand, each absent when not set,tty,pwd,limit(50 when absent, 200 at most) andstats(false when absent). Its result hasrows, best first: each row hasconversation(a row ofconversations.recentwhosesummary.titleis the title with its secrets replaced),short_id, andtitle_rangesandhit, absent when not set. A hit haskind(title,prompt,answerorcommand; a kind that a reader does not know reads asunknown),seq,turn_id(absent outside a turn),at,history_cursorandlines, each atextwith itsranges. A range is a half-open pair of Unicode code points,startandend. The result also hasscope,current,partial(row_caportime_cap; an unknown reason reads asunknown),index(percent) andstats(rows_scanned,conversations_scored,titles_scored,elapsed_us), each absent when not set. New methodconversation.search(scoperead, no command id) withconversation_id,queryandlimit(20 when absent, 50 at most). Its result hashits, newest first, andtitleandpartial, absent when not set. efrd refuses both methods to a peer that is not the user's, and announces them with the new capabilitysearchinhello. A daemon from before refuses both as frames that are not valid. New fixturesconversations_search_params.json,conversations_search_result.json,conversation_search_params.json,conversation_search_result.json,search_kinds.jsonandsearch_partials.json;hello_result.jsonnow setssearch. - Version 1, additive: the cgroup of a background job (ADR 0021).
JobInfogainscgroup(absent from an efrd before 0.0.5) withoff(why the job has no cgroup of its own),memory_bytes(now while the job runs, the most at its end) andcpu_usec, each absent when not set. The fixturesjobs_list_result.json,jobs_stop_result.json,events/job_started.jsonandevents/job_started_moved.jsonnow set it. - Version 1, additive:
call.backgroundalso takes the id of a call whose command was left running in the hidden shell, and moves that command, with the same preview and the same refusals. The command'sshell_command_endedthen hashow: moved, which the enum had already. A daemon from before refuses such a call as one that does not run. No fixture changes. - Version 1, additive: the delete of a conversation. New method
conversation.delete(scopeterminal, with a command id) withconversation_id, andforceandstop(each false when absent). Its result isdeleted(conversation_id, andtitle,jobs_stoppedandnot_removed, each absent when not set) orheld(tty, andtitlewhen set) when another open terminal holds the conversation andforceis not set; a kind that a reader does not know reads asunknown. A running turn, a queued prompt or a compaction refuses it asbusyunlessstopis set, and an undo that runs refuses it asbusyalways; the error'sdatais{"busy": "turn"}or{"busy": "undo"}. A conversation that does not exist, or that is deleted, isnot_found. New event kindconversation_deletedwithorigin: the last event of the conversation and the only one that stays in the log; a subscription of the conversation ends after it. A daemon from before refuses the method as a frame that is not valid. New fixturesconversation_delete_params.json,conversation_delete_result.json,conversation_delete_result_held.jsonandevents/conversation_deleted.json. - Version 1, no change of the frames: the
summary.titleof each row ofconversations.recentis now the title with its secrets replaced, the same title that the row ofconversations.searchwith an empty query shows. - Version 1, additive: remote devices. New methods
device.pair_start(scopeadmin, a stream, with an optionalname; its items arestartedwithpairing_id,expires_atandqr,pendingwithpairing_id,device_name,node_name,user_login,key_fingerprintand an optionalos,pairedwithpairing_idanddevice_id, andendedwithpairing_idandreason:refused,expiredorattempts; a reason that a reader does not know reads asunknown),device.pair_confirm(scopeadmin, withpairing_idandaccept; its result hasdevice_id, absent for a no),device.enroll(scoperead, withtoken,nameandpublic_key, the 65-byte P-256 point as standard base64; its result hasdevice_idandscopes),device.prove(scoperead, withdevice_idandsignature, DER as standard base64; its result hasscopes),device.list(scopeadmin, no params; its result hasdevices, each withdevice_id,name,key_fingerprint,node,scopes,created_at, andlast_seen_atandrevoked_at, each absent when not set) anddevice.revoke(scopeadmin, withdevice_id; its result hasrevoked_at). None has a command id. Thehelloresult gains an optionalprove(peer_node,daemon_node,daemon_tailnet) for a remote connection. The message that a device signs is in the section "Remote devices". A daemon from before refuses the methods as frames that are not valid, and this efrd refuses them withinvaliduntil it pairs devices. New fixturesdevice_*_params.json,device_pair_start_item_started.json,device_pair_start_item_pending.json,device_pair_start_item_paired.json,device_pair_start_item_ended.json,device_pair_confirm_result.json,device_enroll_result.json,device_prove_result.json,device_list_result.json,device_revoke_result.json,hello_result_remote.json,pair_ends.json, and the vectorsdevice_prove_message.jsonandpairing_qr_url.json. - Version 1, additive: streams for the phone. New capability keys in
hello:drafts_coalesced(a slow subscriber gets merged drafts, not gaps),snapshot_live(the snapshot haslive),approval_local_only(theapproval_requestedevent haslocal_only) anddevice_prove(the connection takesdevice.enrollanddevice.prove). The snapshot ofconversation.subscribegains an optionallivewithturn_id,after_seq,index,text,text_bytes, andreasoningandreasoning_title, each absent when not set. Theapproval_requestedevent gainslocal_only, false when absent: only the user's own machine may answer the question. New fixturesconversation_subscribe_item_snapshot_live.jsonandevents/approval_requested_local_only.json. No fixture changes. - Version 1, no change of the frames: the docs now say that every text offset counts
bytes of UTF-8 (section "Text offsets"), and the
indexofassistant_message_updatedandassistant_message_completedcounts only the messages of the turn that have text, as the draft doc said. New filedocs/protocol.schema.json, the protocol for code generators; the schema ofprevious_exits_this_turninexit_requestedis now a list of two items, not a tuple, and its wire form is the same. - Version 1, no change of the frames: efrd answers
device.proveand serves the tailnet listener. The section "Remote devices" lists its answers, with thedataofre_pairand ofstarting. Thehelloresult of a remote connection hascapabilities.device_prove. - Version 1, no change of the frames: efrd now answers
device.pair_start,device.pair_confirm,device.enroll,device.listanddevice.revoke.device.pair_startisconflictwith the data{"remote": "off"}when[remote] enabledis false, andbusywith{"remote": "not_listening"}while the listener does not run. The section "Remote devices" now gives the rules of a pairing (the token, the count of wrong tokens, the time to answer) and the key fingerprint with its test vector. - Version 1, no change of the frames: efrd announces
drafts_coalesced,snapshot_liveandapproval_local_onlyon every connection. It fillslivein the snapshot while a model call of a turn runs, and setslocal_onlyinapproval_requested. A resume (after_seq) withdraftshas no snapshot, so its first drafts hold the reasoning of the running turn and the text of its current message from offset 0; a client cuts the bytes that it has. efrd refusesinput.respondfrom a phone withforbidden, whatever scopes the phone holds. - Version 1, no change of the frames: the docs now describe the tailnet WebSocket
(the URL, the admission, the handshake, the message limits, the pings and the
helloprobe) and what a phone may not do, in the section "Remote devices". The docs ofdraftnow say that the daemon merges the drafts of a slow subscriber; it no longer drops them. - Version 1, no change of the frames: on the tailnet listener, the
helloresult has emptypaths, efrd ignores thettyof the hello, and aprompt.sendwhosecontextnames attyisinvalid. A held reasoning draft of a slow subscriber now waits past the end of a message and the start of a tool call, and goes only at the end of its turn. efrd closes a connection from an address outside the ranges of Tailscale before its whois. TheOriginheader may no longer name the MagicDNS name of the node, and thefmember of the QR payload is for display only.device.enrollrefuses a public key that is not a point of the P-256 curve withinvalid. The count of wrong tokens starts from zero when a pairing opens while no other one waits for a phone. - Version 1, additive: the
approval_requestedevent gains an optionalreason, one plain line that tells why the permission mode of the turn asks, such ascautious asks before any network access.Absent means no reason, also from an older efrd. A client shows it below the summary. No capability key: a client that gets noreasonshows the question as before. efrd makes the line from the reasons of the engine's decision and the mode of the turn. The terminal does not show it, so its output does not change. New fixtureevents/approval_requested_reason.json. No fixture changes. - Version 1, additive: the lists of the phone. New streaming method
conversations.watch(scoperead, no command id) with an optionallimit(20 by default, at most 50). Its first item issnapshotwithconversations, the rows ofconversations.recentwithout attyand apwd. Thenchangedwithconversationcomes each time a commit changes the title, the status, the working directory, the scope, the project or the count of turns of a conversation, andremovedwithconversation_idfollows a delete. A watch that falls behind ends withoverflow, and the client watches again. New capability keyconversations_watchinhello. A daemon from before refuses the method as a frame that is not valid, so a client without the key asksconversations.recentagain. New fixturesconversations_watch_params.json,conversations_watch_item_snapshot.json,conversations_watch_item_changed.json,conversations_watch_item_removed.jsonandhello_result_remote_watch.json. No fixture changes.