Skip to content

hey local: ask the HEY app on this computer to act for you - #640

Closed
jeremy wants to merge 15 commits into
mainfrom
hey-local
Closed

jeremy wants to merge 15 commits into
mainfrom
hey-local

Conversation

@jeremy

@jeremy jeremy commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Context

  • The HEY desktop app is opening a door for agents: while "Let agents use HEY" is on in its Settings, it serves its own commands as MCP over a Unix socket, and a send waits in its window until the person presses Send. The app's side is being built in hey-native-apps (the door on Linux, branch wt/localmode-door; the confirmation sheet, #11223).
  • Nothing in hey-cli spoke to it. The agent eval's hey-local capability probes hey local --help, so today every task that needs it is skipped.

Change

  • hey local tools lists what the app offers now, with each command's effect (read, reversible or reaches_people) and its arguments.
  • hey local call <command> [json] [--arg name=value] runs any of them. A value is read as JSON where it is JSON (sender=42 is a number, decision=out a word).
  • Sugar for the first commands: hey local imbox, hey local screener list|approve|deny <sender>, hey local send <draft>. deny says how to undo it (hey local screener approve <sender>), in its line and its breadcrumb.
  • An agent can carry a message end to end (the door's drafts, hey-native-apps#11400): hey local write --to --subject -m or hey local reply <thread> -m (a thread from hey local imbox's Thread column) saves a draft, sends nothing, and answers its id with the command that asks HEY to send it; hey local send <id> then waits for the person to press Send in HEY (exit 9). hey local drafts lists the app's drafts, which HEY would send now and which are scheduled. The message is plain text from -m or stdin.
  • The door's refusals read plainly, with their exit codes: not_an_address names the pieces of --to (1); not_saved says which limit, 1 where the caller's words or --to passed it and 7 where a reply's thread did; not_a_sender names the address the account lost (7); a reply's unreadable thread (7); a send of a draft HEY won't send now (1, with the drafts it would); session_ended, a call cut off by a sign-out or account switch, says it may have been done and to check HEY (7), never "refused".
  • A call the app answers pending (a send waiting in HEY) prints "Waiting in HEY ... Nothing has been sent", with To and Subject, and exits 9, a new code in hey help exit-codes: neither done nor failed. JSON gets the envelope with meta.status: "pending", the confirmation id and the snapshot; --ids-only prints the confirmation id and --count nothing, both with the words on stderr. Every command checks for pending, not only send, and the sugar refuses an answer that isn't the shape it shows. Nothing here can confirm a send, and there is no --yes.
  • No socket, or a stale one nothing answers on: exit 6, "Turn on "Let agents use HEY" in the HEY app's Settings (General)". A refusal carries the app's reason in meta.refusal: exit 1 where it names an argument (the hint lists what it takes, e.g. sender takes one of: 51, 52) or for an unknown command, 3 for the app signed out of HEY, 7 otherwise. A connection the app closes unanswered (another user account) exits 6 and says so. Security's red is main's too: govulncheck's GO-2026-6859 in net/http, fixed in Go 1.27.4, while the repository pins 1.27.3; nothing here calls it, and the toolchain bump is its own pull request.
  • Socket: --socket, else HEY_LOCAL_SOCKET, else $XDG_RUNTIME_DIR/hey/agent.sock. With no absolute XDG_RUNTIME_DIR it asks for --socket rather than guess, as the door's contract asks.
  • hey local never reaches HEY's servers and needs no sign-in: it skips the credential migration, the account scope and the local config trust gate.
  • The client is internal/localapp: plain MCP written out, JSON-RPC 2.0, one message per line; initialize and notifications/initialized first, then tools/list (following nextCursor) and tools/call; it answers a ping and passes over notifications. internal/localapp/localapptest is a fake door for tests.
  • Docs: docs/agents.md (new section, effects table), docs/cli.md (pointer), hey help exit-codes, hey help environment, .surface. Not in root help, on purpose: it is reachable from hey commands and hey local --help, and shows nothing until the app ships the door.

Verification

  • Go tests against the fake door, in the client (20) and the commands (49): list, call, typed arguments, a JSON object with --arg over it, pending, a refusal never pending, no socket, a stale socket, no runtime folder, the flag over the environment, a JSON-RPC error, malformed replies (not JSON, not 2.0, another request's id, no result, the wrong shape), an Imbox in another shape, a ping and a notification before the answer, a timeout, signed out of the app. Every command test fails if anything reaches HEY's server, and runs with no HEY_TOKEN.
  • A fresh-context review (round 1) found 1 High (pending under --ids-only/--count exited 1), 3 Medium (wrong refusal mapping, sugar not checking for pending or for the answer's shape) and 4 Low; all but one fixed in the second commit (the one declined: root help, above), each with a test shown red against the first commit's code (10 cases across 7 tests). Round 2 reviewed the fixes alone: 1 Medium (a refused id over a million hinted as 1.731280000123e+12, and the refusal is the one place an agent learns draft ids) and 2 Lows, fixed in aa0698d, red first; a third Low (--markdown printing raw JSON as bytes) did not reproduce. Round 3 reviewed that fix: no High or Medium; its 2 Lows (an empty list of cases, an unmapped write error after initialize) fixed in 5239129. Then Copilot, cubic and Greptile reviewed 5239129: 34 threads, 32 fixed in 828511c (12 new tests, each red against 5239129's code first) and 2 declined in their threads. cubic's next passes found 3 more, fixed in e27c34f and a67ec14: a send the app answers otherwise now points at the app, not hey upgrade, and keeps a plain-text answer. Copilot's pass on a67ec14 found listings reading a missing field as a fact (a decided sender without screened_in shown as screened out, no has_more as a complete Imbox): a111a60 requires every field whose zero value would say something, as the contract has the app always give it. Among the fixes: clientInfo.version, each call bound by its own context, hey local send refusing an answer that isn't pending, the undo hint keeping --socket, and screener list / imbox --ids-only and --count with their notices.
  • The drafts commands' tests (drafts, write, reply, a reply answered on another thread, write then send waiting in HEY, write with no message, eight refusal cases, three draft-shape cases) and session_ended's each failed first against the commands as they were, with a type-only stub so they compiled. A fresh-context review of them found 1 Medium (--ids-only on a saved draft failed with exit 1, inviting a retry that writes a second) and 5 Lows, all fixed in 887c69f, red first: --ids-only now prints a saved draft's id; unavailable from write or reply, which the app also answers for a draft saved then gone before HEY listed it, says to check hey local drafts; --to is required. Round 2 over those fixes: no High or Medium; its stderr warning for a draft HEY won't send under --ids-only taken in 530498f. cubic's three threads on 3e90dad (a saved draft's kind, the app's refusal detail, the reply docs) fixed in fcf918b, red first.
  • Shown red first: with the pending branch unwired, TestLocalSendWaitingInHEYIsPendingNotSentNorFailed and TestLocalCallOfASendIsPendingToo failed with err = <nil> (exit 1): the send read as success. Mutations that dropped the refusal check, the answer-id check or ECONNREFUSED each turned a client test red.
  • make check on Linux (thelio) at fcf918b: exit 0, 123 packages, lint 0 issues; at 828511c the same; at a111a60, at a quarter width for thelio's load: go test ./..., go test -race over internal/cmd, internal/localapp and internal/output, golangci-lint 0 issues, check-surface ok; go test -race -count=2 over internal/localapp, internal/output and the TestLocal tests passes; GOOS=windows and GOOS=darwin builds compile. Linux caught two things macOS did not: a door that closes on a caller reaches the client as a reset (fixed in 0882207), and a test server that closed too early (aa0698d).
  • Checked against the door's contract, settled 2026-11-10 by the app lane: socket path, framing, initialize / tools/list / tools/call, the seven tool names and argument types (with fa0b3260966's drafts), the Done, Pending and Refused shapes, _meta["com.hey/effect"].
  • Not run against the real app: its door isn't built yet. The first real run is the eval's proof run.

Alternatives

  • The go-sdk's MCP client, already a dependency: its newest protocol tries server/discover before initialize and negotiates versions, more than three methods on a local socket need, and a malformed line is the SDK's to handle, not ours to report plainly.
  • --arg typed by each tool's JSON Schema: it would cost a tools/list per call. A word that would read as a number is quoted (--arg 'subject="2026"').
  • Verifying the app's uid from the client (SO_PEERCRED): the default socket sits in the person's own 0700 XDG_RUNTIME_DIR, which another account can't write into; the door checks the caller's uid on its side.
  • macOS is not built. The Mac app needs each agent paired in the app and the caller's code signature checked first, since there a socket would let any program skip the Keychain prompt that guards HEY's sign-in. For hey local that would be the same client over the Mac's socket path plus whatever pairing the app asks for, likely a token it issues; nothing to build until that exists.

Follow-ups

  • No command edits or deletes a draft (the door offers none): an agent that wrote the wrong one writes another, and the person deletes the first in HEY. draft.reply reaches only Imbox threads.
  • The eval's server-side checks call hey box view sent|spam --all --json, which hey-cli doesn't list yet: a separate pull request.
  • The eval needs a released build to score the customer role: the eval machine runs a released hey-cli, so hey local counts only once this is merged and a release is cut.

The HEY desktop app is opening a door for agents: while "Let agents use
HEY" is on in its Settings, it serves its own commands as MCP over a
Unix socket, and a send waits in its window for the person to press
Send. Nothing in hey-cli spoke to it, and the eval's hey-local
capability probes hey local --help, which failed.

hey local now does:

- hey local tools lists what the app offers, with each command's effect
  (read, reversible, reaches_people).
- hey local call <command> [json] [--arg k=v] runs any of them.
- hey local imbox, hey local screener list|approve|deny and hey local
  send <draft> are sugar for imbox.list, screener.list, screener.decide
  and message.send.
- A call the app answers pending (a send waiting in HEY) prints
  "Waiting in HEY", says nothing has been sent, and exits 9, a new
  documented code (hey help exit-codes): neither done nor failed.
- No socket, or a stale one nothing answers on, fails with exit 6 and
  says to turn on "Let agents use HEY" in the app's Settings.

The client (internal/localapp) is plain MCP written out: JSON-RPC 2.0,
one message per line, initialize and notifications/initialized first,
then tools/list and tools/call, answering a ping and passing over
notifications. The socket is --socket, else HEY_LOCAL_SOCKET, else
$XDG_RUNTIME_DIR/hey/agent.sock, matching the door's contract; with no
absolute XDG_RUNTIME_DIR it asks for --socket rather than guess. hey
local reaches HEY's servers never: it skips the credential migration,
the account scope and the local config's trust gate.

Verified by Go tests against a fake door (localapptest), in the client
and in the commands: list, call, typed arguments, pending, a refusal
never pending, no socket, a stale socket, no runtime folder, a JSON-RPC
error, malformed replies (not JSON, not 2.0, another id, no result, the
wrong shape), a ping before the answer and a timeout. The command tests
fail if anything reaches HEY's server. The pending tests were run red
first, with the pending branch unwired: the send read as success.
Mutations that drop the refusal check, the id check or ECONNREFUSED
each turned a client test red.

Rejected: the go-sdk's MCP client, already a dependency. Its newest
protocol tries server/discover before initialize and negotiates
versions, which is more than three methods on a local socket need, and
its handling of a malformed line is the SDK's to decide, not ours to
report plainly. --arg values are typed as JSON rather than by the
tool's schema, so a call needs no extra tools/list; a numeric-looking
word is quoted. A Mac door is not built: there a socket would let any
program skip the Keychain prompt guarding HEY's sign-in, so the app has
to pair each agent and check its caller's signature first.
A fresh review of the first commit found:

- A pending send under --ids-only or --count exited 1: writeLocalPending
  passed back the writer's "requires list data" error. It now always
  returns ErrPending; --ids-only prints the confirmation id, --count
  prints nothing, and both say "Waiting in HEY" on stderr. A --jq filter
  that fails on the answer no longer turns pending into a failure.
- Only call and send looked for pending. callLocal now does, so the
  sugar can't read a waiting answer as done ("Sender 42 screened in" or
  "Nothing in the Imbox", exit 0).
- The sugar took any shape: {} decoded as an empty Imbox, or as a
  decision, reported done. Each now requires what it shows: the Imbox's
  threads, both of the Screener's lists, and a decision said back for
  the sender and the way it was asked.
- A refused id exited 7 with no hint, and "disabled" pointed at the
  setting when the door had answered. The app's Arg.Ref refuses with
  unavailable or disabled and names arg and available, so a refusal
  naming an argument is now a usage error (1) whose hint lists what it
  takes. unavailable with no arg (a list the app could not load) stays 7.
- An unknown tool (-32602 from the door) is a usage error pointing at
  hey local tools.
- Pending is the status alone: a confirmation id the client doesn't
  expect (a string) read as done. It is kept as the app wrote it.
- --arg took trailing junk: sender=42} sent 42. The value must be the
  whole JSON value, or it is a word.
- A connection the app closes unanswered, as it does a caller of
  another user account, exits 6 and says so, not 7.

Verified: a test per finding, 10 cases across 7 tests, run red against
the first commit's code with the new tests, then green. The lint
findings from make check (net.Listen with no context, one prealloc)
are fixed with one localapptest.Listen for every hand-rolled socket.

- hey local imbox's table showed the thread (topic) as its only id while
  --ids-only prints the row's. It shows both, as hey box view does.

Not changed: hey local stays out of the curated root help until the app
ships its door.
…aller

On Linux, the app closing a connection with the request unread reaches
the client as ECONNRESET or EPIPE, not EOF, so a caller of another user
account, which the door closes without a word, got exit 6 with no hint.
TestLocalAConnectionClosedUnansweredSaysWhoTheAppAnswers failed on
thelio (Linux) and passed on macOS; both now read the reset as a close.
…ing to take is the app's

A second review round, of the fixes alone, found:

- The refusal was decoded as floats, so an id of a million or more was
  hinted as 1.731280000123e+12, which hey local send then refuses. The
  refusal is the one place an agent learns the app's draft ids. It is
  now decoded with numbers as written, in the hint and in meta.refusal.
- A refusal naming an argument with nothing available (the app holds
  no senders now, or could not load them) exited 1 as the caller's
  mistake. It stays 7, saying the app holds nothing the command takes.
- A pending answer's confirmation and snapshot are handed to the writer
  as values rather than raw JSON. The reviewer expected --markdown to
  print raw JSON as bytes; the assertion added for it passed on the old
  code too, so that one was not reproduced, and the decode stays as the
  shape every other writer path gets.

Also: TestTheAppsOwnMessagesBeforeAnAnswerArePassedOver closed its
hand-rolled server's connection after reading the ping's answer, so
under -race on Linux the client's notifications/initialized met a
closed socket (broken pipe). The server holds it open until the test
ends, as the app does; 20 race runs pass.

Verified: the refusal and nothing-available tests were run red against
the previous head's local.go (hint "sender takes one of: 51,
1.731280000123e+12"; deny exit 1), then green.
…ialized names the close

Round 3 of the review, two Lows:

- A refused choice whose live cases are empty hinted "<arg> takes one
  of: " and exited 1. Empty cases now read as empty ids do: the app
  holds nothing the command takes, exit 7. No shipped command has a
  choice that can empty; the test covers it with a made-up refusal.
- notifications/initialized's write error came back unmapped, so an app
  closing right after initialize gave a bare broken pipe. It now goes
  through the same mapping as every other exchange.
@jeremy
jeremy requested a review from a team as a code owner November 11, 2026 07:51
Copilot AI balanced review requested due to automatic review settings November 11, 2026 07:51
Comment thread internal/cmd/local_screener_list.go Resolved
Comment thread internal/cmd/local_screener.go Resolved
Comment thread internal/cmd/local_send.go Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The missing required MCP clientInfo.version can prevent all tool use, while unresolved response-validation and listing-output defects can falsely report success, hide incomplete results, or fail valid listings.

6 open issues
What changed in this PR

Adds hey local so agents and scripts can use the HEY desktop app through its local Unix socket, without CLI sign-in or direct server access. Sending requires confirmation in the app.

Changes:

  • Adds tool discovery, generic calls, and shortcuts for Imbox, screening, and sending.
  • Introduces pending exit code 9, refusal handling, and socket-specific errors.
  • Adds fake-app tests, command discovery entries, and user documentation.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
internal/​output/​codes.go Defines pending status and exit code 9.
internal/​output/​codes_test.go Tests pending exit-code mapping.
internal/​localapp/​socket.go Resolves socket path precedence.
internal/​localapp/​localapptest/​door.go Provides a fake app socket for tests.
internal/​localapp/​errors.go Defines connection and protocol errors.
internal/​localapp/​client.go Implements the local MCP client.
internal/​localapp/​client_test.go Tests protocol exchanges and failures.
internal/​cmd/​root.go Registers local commands and handles pending exits.
internal/​cmd/​local.go Shares connection, refusal, and output handling.
internal/​cmd/​local_tools.go Lists available tools and effects.
internal/​cmd/​local_test.go Tests commands against the fake app.
internal/​cmd/​local_send.go Requests confirmation of a draft send.
internal/​cmd/​local_screener.go Shares screening decisions and undo guidance.
internal/​cmd/​local_screener_list.go Lists waiting and decided senders.
internal/​cmd/​local_screener_deny.go Adds sender screening-out command.
internal/​cmd/​local_screener_approve.go Adds sender screening-in command.
internal/​cmd/​local_imbox.go Displays the app’s loaded Imbox.
internal/​cmd/​local_config_trust.go Exempts local commands from config trust checks.
internal/​cmd/​local_call.go Parses arguments for generic tool calls.
internal/​cmd/​help_topics.go Documents pending exits and socket configuration.
docs/​cli.md Introduces the local command family.
docs/​agents.md Explains local integration and confirmation behavior.
.surface Records the new command surface.

Review effort: Balanced

Comment thread internal/localapp/client.go Resolved
Comment thread internal/cmd/local_imbox.go Resolved
Comment thread internal/cmd/local_screener_list.go Resolved
Comment thread internal/cmd/local_send.go Resolved
Comment thread internal/localapp/client.go Resolved
Comment thread internal/localapp/client.go Resolved

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 23 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread internal/cmd/local_screener.go Resolved
Comment thread internal/cmd/local_send.go Resolved
Comment thread docs/cli.md Resolved
Comment thread internal/cmd/local_screener_list.go Resolved
Comment thread internal/cmd/local_screener_list.go Resolved
Comment thread internal/localapp/localapptest/door.go Resolved
Comment thread docs/cli.md Resolved
Comment thread internal/cmd/local_tools.go Resolved
Comment thread internal/cmd/local.go Resolved
Comment thread internal/localapp/localapptest/door.go Resolved
Copilot, cubic and Greptile reviewed the head. Fixed here:

- initialize sends clientInfo.version, which MCP requires.
- Each call is bounded by its own context. Only Dial's was applied, so
  a call made with a shorter deadline could wait on an app that holds
  the socket open; a call now times out on its own context even after
  a dial with none. A dial whose context ended is a timeout, not a
  connection failure.
- A tools/list with no tools, or a tools/call with no content, is no
  answer rather than an empty list or a done call. A pending answer is
  still pending without content.
- hey local send reported an answer that was not pending as "HEY ran
  message.send", exit 0. A send the app took is pending; anything else
  exits 7.
- An undo hint drops the --socket it ran with, so pasting it could
  reach another app. The breadcrumb and line now carry it, quoted.
- hey local screener list: --ids-only and --count failed (no list), and
  "Nobody waits" showed while waiting_count said otherwise. Both lists
  feed the ids and count, waiting first, and senders waiting beyond
  what the app lists are named in every format.
- hey local imbox: has_more reached only the envelope. --ids-only,
  --count, --quiet and --markdown say so on stderr, and the styled
  empty Imbox says so rather than "Nothing in the Imbox".
- --arg given twice under one name is a usage error, not the last one.
- hey local tools --styled lists each command's arguments.
- A decision the app says it took otherwise is "not what was asked",
  pointing at the app, not at an old hey.
- XDG_RUNTIME_DIR relative is said as "not set to an absolute path".
- Wording: exit 9 names hey local call too; docs/cli.md says Linux only
  and spells out the screener subcommands; docs/agents.md says hey
  local send takes the app's own draft id and where to learn one.
- Tests: hey local's exemptions from account scope, credential
  migration and the local-config gate are held; the signed-out hint is
  asserted exactly; the fake door's comment names its real methods, its
  Offer declares arguments, and it reads lines up to the client's cap.

Verified: each fix's test run red against the previous head's code,
then green (12 tests). The per-call timeout test first passed on the
old client for the wrong reason, the dial helper's own deadline; it
now dials with none and was red ("the call outlived its own context").

Not changed, with reasons in the threads: the fake door's one-call
writes (net.Conn.Write writes all or errors), the styled pretty-JSON
branch of hey local call (there is no shared writer for arbitrary JSON
to call), and hey local's place in root help.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 15 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread internal/cmd/local_send.go Resolved
Comment thread internal/cmd/local_send.go Resolved
A send the app answered without pending was reported as an answer this
hey could not read, with the hint to run hey upgrade, and a plain-text
answer was dropped, leaving "it is not pending: " with nothing after it.
An answer that is not pending may still have done something in the app.

Now it is the app's answer, with the hint to check in the HEY app what
it did, and a text answer is quoted when there is no structured one.

TestLocalASendAnsweredOtherwisePointsAtTheApp failed on 828511c on both
the hint and the dropped text, and passes now.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread internal/cmd/local_test.go Resolved
The send and screener-decision tests checked only that the hint did not
say upgrade, so an empty or unrelated hint would have passed them.

Both now require "Check in the HEY app what it did". With that hint text
changed in local.go, both failed; restored, both pass.
@jeremy
jeremy requested a balanced review from Copilot November 11, 2026 08:22
@jeremy

jeremy commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

@greptileai review — the head is now a67ec14, after the fixes for the first round of bot threads.

greptile-apps[bot]
greptile-apps Bot previously approved these changes Nov 11, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved response-validation issues can falsely report Imbox completeness, return the wrong exit code for queued confirmations, and print invalid sender IDs or invented screening decisions.

1 open issue
2 previously missed issues

In code that hasn't changed since last review

Medium severity Require has_more before reporting thread-list completeness

internal/​cmd/​local_imbox.go:59

The shape check accepts both {"threads":[]} and {"threads":[],"has_more":null}. Both become HasMore == false, so styled output says “Nothing in the Imbox” and JSON asserts meta.has_more: false, although the app never supplied that information. Require a non-null has_more boolean before reporting completeness; explicit false should remain valid.

Medium severity Preserve pending status when reply content fails decoding

internal/​localapp/​client.go:157

A reply with content: "unexpected" and structuredContent: {"status":"pending","confirmation":7} fails decoding inside request, bypassing Pending(). The CLI therefore exits 7 instead of 9 despite the app explicitly reporting a queued confirmation. Read the raw result first and preserve a valid pending marker when content decoding fails, while retaining JSON-RPC validation and the refusal check.

6 resolved since last review

Review effort: Balanced

Comment thread internal/cmd/local_screener_list.go Resolved
A decided sender without screened_in was listed as screened out, a null
or id-less sender as sender 0, which the decision commands then reject,
an Imbox answer without has_more as complete ("Nothing in the Imbox",
meta has_more false), and a screener answer without waiting_count as
nobody waiting. Each read an app's missing field as a fact it never gave.

Now each field whose zero value would say something is required, as the
door contract has the app always give it: has_more, waiting_count, every
row's id, and each decided sender's screened_in. Missing, the listing is
an answer that could not be read, exit 7, and prints nothing.

TestLocalListingsRefuseAnswersMissingWhatTheirZeroWouldClaim's seven
cases each failed on a67ec14 and pass now.

Not done: keeping a pending marker when the rest of a tools/call reply
fails to decode. A reply with a broken content array is a broken reply;
exit 7 never says anything was sent, and asking again while the draft
waits answers the same confirmation.
@greptile-apps
greptile-apps Bot dismissed their stale review November 11, 2026 08:33

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread internal/cmd/local_screener_list.go Resolved
@jeremy

jeremy commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

Review state at a111a60: review threads are at 0 unresolved (39 in all). Everything below is for whoever merges this; nothing waits on a decision.

Reviewers on the final head

  • cubic reported on a111a60. Its one finding is declined in its thread (below).
  • Copilot last reported on a67ec14. It was re-requested once on this pull request, and no review of a111a60 had come after 25 minutes. a111a60 is the fix for its a67ec14 thread.
  • Greptile last reported on a67ec14 (its automatic approval). It was asked once by comment and has not reviewed a111a60.
  • A fresh-context review ran three rounds before the bots. The third had no High or Medium findings.

Declined, with the reasoning in each thread

Copilot's review-body findings on a67ec14 (review)

  • Require has_more before reporting completeness: fixed in a111a60, along with every other field whose zero value would claim something.
  • Preserve pending status when the reply's content fails decoding: declined. A reply whose content isn't an array is a broken reply, and hey reads none of it. Exit 7 never says anything was sent, and asking again while the draft waits returns the same confirmation, so nothing is lost.

Checks: everything is green except Security, which fails on main too. govulncheck reports GO-2026-6859 in net/http, fixed in Go 1.27.4, while the repository pins 1.27.3. The toolchain bump is its own pull request.

The eval needs a released build to score the customer role: after this merges, a hey-cli release has to be cut and installed where the eval runs.

The door now answers session_ended when a sign-out or account switch cuts
off a call while it runs, and what the call asked may already be done.
hey local read it as any refusal: "HEY refused screener.decide:
session_ended", which says nothing happened, with no hint.

Now it says HEY's session ended while it ran the command and that it may
have been done, with the hint to check in HEY before running it again.
It still exits 7 with the reason in meta.refusal; docs/agents.md says so.

TestLocalACallCutOffBySessionEndSaysItMayHaveHappened, through screener
deny and call, failed on a111a60 and passes now.
…s one

An agent could ask the HEY app to send a draft, but had no way to make
one or learn its id: no command listed or wrote the app's drafts, so
hey local send could not work end to end. The door now offers
drafts.list, draft.write and draft.reply (hey-native-apps#11400).

Now:
- hey local drafts lists the app's drafts, the last saved first, with
  which HEY would send now (sendable) and which are scheduled.
- hey local write --to --subject -m, and hey local reply <thread> -m,
  save a draft and send nothing. They answer the draft with the
  command that asks HEY to send it, or say HEY can't send it from here.
- hey local send takes that id, or a draft's made_as, and still exits
  9: it waits for the person to press Send in HEY.
- The new refusals read plainly, with their exit codes: not_an_address
  names the pieces (1); not_saved says which limit, 1 where the caller's
  words or --to passed it and 7 where a reply's thread did; not_a_sender
  names the address the account lost (7); a reply's unreadable thread
  (7) and a send of a draft HEY won't send now (1, or 7 with none) too.
- A draft missing its id or sendable is unreadable, as other listings.

docs/agents.md carries the flow and a table of the refusals.

Each new test failed first against the commands as they were, with a
type-only stub so they compiled: drafts, write, reply, a reply answered
on another thread, write then send waiting in HEY, write with no
message, eight refusal cases and three draft-shape cases.

Not done: --ids-only on write and reply (a mutation answers one object,
as hey local screener's decisions do); editing or deleting a draft,
which the door does not offer.
govet's shadow check flagged the loop's err shadowing the call's. No
behaviour changes; make check on thelio found it.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 10 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/agents.md Resolved
Comment thread internal/cmd/local_drafts.go Resolved
Comment thread docs/agents.md Resolved
Comment thread internal/cmd/local.go Resolved
… saved

A fresh-context review of the drafts commands found:
- --ids-only or --count on write, reply or a call of draft.write saved
  the draft, then failed with exit 1, which reads as nothing happened;
  a retry wrote a second draft. (Medium)
- The app answers unavailable for a draft it saved that went before HEY
  listed it, not only for a thread it couldn't read; both read as
  nothing having happened.
- A reply too large exited 1 with "Shorten the message", though its
  quote, not the caller's words, may be what passed the limit.
- docs/agents.md gave disabled as exit 1 only; it is 7 with none sendable.
- write without --to silently wrote a draft to nobody.
- not_an_address's hint named --to through hey local call too.

Now --ids-only prints a saved draft's id and --count 1, through write,
reply and call alike; unavailable from write or reply says the draft
may have gone and to check hey local drafts before writing again; a
reply past a limit is 7, its hint naming the quote; --to is required
(--to "" still writes a draft to nobody); the docs match.

TestLocalADraftSavedGivesItsIDToIDsOnly, TestLocalWriteAsksForTheToLine
and four refusal cases failed on 3e90dad and pass now; two more cases
(a written draft gone, no draft sendable) cover paths that had no test.
The second review round of the drafts fixes found no High or Medium; of
its Lows, two are cheap. Under --ids-only or --count, a draft HEY would
not send from here printed its id alone, so an agent asked to send it
and met a refusal; and write's --help did not say --to is required.

Now the summary goes to stderr in those modes, as a waiting send's does,
and --to's usage says it is required. TestLocalADraftHEYWontSendSaysSo-
UnderIDsOnly failed on 887c69f and passes now.

Left: more --count cases on the paths --ids-only already covers.
cubic's review of 3e90dad found: a reply the app saved as a new draft,
or a message saved as a reply, read as done; the plain messages of the
new refusals dropped any detail the app gave; and the docs described
the reply's recipients by a rule of their own.

Now a draft saved as another kind than asked is the app's answer to
explain (exit 7, check in HEY), as one on another thread already was;
the app's detail follows every refusal's message; and the docs say a
reply is addressed as HEY's own Reply addresses one, with hey local
drafts showing whom it goes to.

TestLocalADraftOfAnotherKindIsTheAppsToExplain (its reply case given
the asked thread, so only the kind differs) and
TestLocalARefusalKeepsTheAppsDetail failed on 530498f and pass now.
@jeremy

jeremy commented Nov 12, 2026

Copy link
Copy Markdown
Member Author

Review state at fcf918b, after the door's drafts (hey-native-apps#11400) and session_ended: review threads are at 0 unresolved (43 in all). Nothing waits on a decision.

  • Fresh-context review of the drafts commands (8875d21..3e90dad): 1 Medium and 5 Lows, all fixed in 887c69f. Round 2 over those fixes found no High or Medium; one of its Lows was fixed in 530498f.
  • cubic last reported on 3e90dad. Its three threads are fixed in fcf918b. It had not reviewed 530498f after 30 minutes.
  • Copilot and Greptile last reported on a67ec14. Each was already re-requested once on this pull request, so neither is asked again.
  • The declines in the earlier summary stand. One new decline, in its thread: the docs point at HEY's own Reply for a reply's recipients and don't restate rules this repository can't check against the app.
  • Checks: make check passes on Linux at fcf918b. Security fails on main too (GO-2026-6859, fixed in Go 1.27.4).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/agents.md">

<violation number="1" location="docs/agents.md:212">
P3: The claim that `hey local drafts` shows whom a draft goes to is only true in JSON. The styled table shows just a truncated `To` column and omits Cc and Bcc. Name the JSON fields here, or say the table shows only `To`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/agents.md
to delete in HEY. The message is plain text, above your name tag; Cc, Bcc, files and a
schedule are yours to add in HEY. A reply answers the thread's last message and is
addressed as HEY's own Reply addresses one; it is saved beside any draft you have on the
thread, never over it. `hey local drafts` shows whom a draft goes to before you send it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The claim that hey local drafts shows whom a draft goes to is only true in JSON. The styled table shows just a truncated To column and omits Cc and Bcc. Name the JSON fields here, or say the table shows only To.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/agents.md, line 212:

<comment>The claim that `hey local drafts` shows whom a draft goes to is only true in JSON. The styled table shows just a truncated `To` column and omits Cc and Bcc. Name the JSON fields here, or say the table shows only `To`.</comment>

<file context>
@@ -207,8 +207,9 @@ thread from the Thread column of `hey local imbox`) saves a draft and answers it
-to, as HEY's Reply does, beside any draft you have on the thread, never over it.
+schedule are yours to add in HEY. A reply answers the thread's last message and is
+addressed as HEY's own Reply addresses one; it is saved beside any draft you have on the
+thread, never over it. `hey local drafts` shows whom a draft goes to before you send it.
 `hey local drafts` marks each draft HEY would send now as `sendable`; a scheduled one sends
 on its own at its time.
</file context>
Suggested change
thread, never over it. `hey local drafts` shows whom a draft goes to before you send it.
thread, never over it. `hey local drafts --json` shows whom a draft goes to (`to`, `cc`, `bcc`) before you send it; its table shows only `To`.

@jeremy

jeremy commented Nov 12, 2026

Copy link
Copy Markdown
Member Author

Reshaped onto profiles in #646, as the design asked: no hey local tree; a profile with "endpoint": "app" routes the ordinary commands through the app, behind HEY_EXPERIMENTAL=app. #646 carries the reads and this PR's internal/localapp unchanged; writes, the wait and exit 9 come in the next PR. Closing this one in favour of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants