Skip to content

feat(lazurio): agents send once per idempotency key, and budíček finds Buddy's thread - #105

Merged
immakermatty merged 5 commits into
mainfrom
agent/DEV-6676-buddy-t4-cli
Oct 22, 2026
Merged

immakermatty merged 5 commits into
mainfrom
agent/DEV-6676-buddy-t4-cli

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 22, 2026 •

Copy link
Copy Markdown

Status: ready for review. Overlay row 24, plan DEV-6676: T4 of "Buddy's chat on Lazurio T3 Code with Hermes as an ACP agent". Stacked on #104 (T1, base branch agent/DEV-6676-buddy-t1-prompt); this PR's diff is only T4. Not merged or released.

Why

budíček wakes Buddy when a bot's task ends. With Buddy's chat on T3 Code, the wake becomes a message in Buddy's one T3 thread (the shared constants of the design, §9):

t3 lazurio buddy thread --json        # {"threadId": "...", "projectId": "..."}
t3 thread send <threadId> --mode queue --idempotency-key <wake_key> --message-file -

Two things were missing:

  • Exactly once. budíček is crash-safe and may send a wake again after a crash. t3 thread send had no key, so a retry duplicated the wake. The MCP tool behind it, t3_thread_send, does take a clientRequestId. But T3 derives the command id from the key within the request namespace of the MCP client, which is the client's session (client:<sessionId>), and every agent command signs in with a session of its own. A key alone would therefore not hold across commands.
  • Which thread is Buddy's, with one conversation and no thread id to track.

What changes

  • t3 thread send --idempotency-key <key> goes through /mcp as an agent (t3_thread_send, recorded as createdBy: "agent", creationSource: "mcp", never as the person's own step) and passes the key as clientRequestId, as a SHA-256 digest of the key with the thread and the sender (T3CODE_ACTING_SENDER and T3CODE_ACTING_FOR of the "Buddy za …" trace, unset for the person's own commands). The same key for another thread, or from another sender, is another message (the trace owner's finding on 752654da11, fixed in 32fe34d648).
  • One request namespace for the agent commands (apps/server/src/lazurio/agentCliRequests.ts; one decorator where server.ts provides the MCP client authenticator). Sessions with the agent commands' label t3 agent cli get the namespace lazurio:agent-cli.
    • The same key, thread and sender therefore name the same orchestration command and message ids in every command and every run.
    • T3's command receipt is committed with the events, and replays them for a command id it has already handled. So a key sends once, also after a restart of T3.
    • Every other MCP client keeps upstream's namespace.
    • The namespace also owns uploads an MCP client prepares, so one agent command can attach what another prepared. Every agent command acts as the Environment's owner.
  • Buddy's one thread: POST /api/lazurio/buddy/thread (buddySurfaceThread.ts, buddySurfaceThreadRoute.ts, in the overlay's route layer).
    • The thread is buddy, on the provider instance buddy, in full access, in Buddy's project. That project is the Buddy layer ~/Lazurio/.agents/buddy (§9), which the Platform's installer adds (P1).
    • A fixed command makes the thread, so two first asks at once make one thread.
    • An archived Buddy thread is unarchived instead of replaced: a new thread would start Hermes on a new session and fork Buddy's memory.
    • A thread with the id buddy counts only in Buddy's project and on the instance buddy. Any other thread with that id is left as it is, archived or not, and nothing is made over it, so a wake never lands in someone else's conversation (Pablo's finding on 79c4bcca68, fixed in 752654da11).
    • Refusals: 404 not-buddy outside Buddy's T3 Code (T3CODE_LAZURIO_BUDDY=1); 409 no-project while Buddy's project is missing, and 409 thread-conflict while a thread with Buddy's id is someone else's.
    • The web surface (T2) will ask the same route when it opens.
  • t3 lazurio buddy thread [--json] asks the route as an agent command: {"threadId", "projectId"}, or {"error": {"code", "message"}} with exit 1 (not_buddy, no_project), or exit 3 when T3 is not running. For this, the agent CLI's connection gets a small postRoute for the overlay's routes.
  • Send outcome (13296f31ff, the Platform's request for budíček). After a send with --idempotency-key, the command asks the new overlay route POST /api/lazurio/agent-cli/send-state (agentCliSendState.ts, scope orchestration:read) what T3 recorded: the receipt of the key's command and the message it made, by the ids T3 derives from the key (agentCliSendIds; the release contract pins upstream's derivation).
    • A repeat of a delivered key prints the first send's result with its commandId. Where T3 cannot replay it, because it checks the thread first (for example, the thread was archived since), the repeat prints "delivery": "delivered" with the message's messageId, runId and the original commandId, and sends nothing (cab095e3b6, Pablo's finding on 13296f31ff).
    • A key that already sent another message to the thread fails with idempotency_conflict and sends nothing. The text is compared after the same trim T3's tool applies when it decodes the message (OrchestratorMcpPrompt), so spaces or a final newline around the same text are the same message.
    • A key whose command T3 rejected fails with command_rejected; that used to be a bare orchestration_error, and a rejected receipt stays rejected.
    • Every other failure keeps its code. A T3 without the route only sends, as before.
  • t3 lazurio capabilities adds "threadSendIdempotency": true, "threadSendOutcome": true and "buddyThread": true.
  • Docs and distribution files:
    • docs/user/agent-cli.md: the key and the Buddy command;
    • the runbook: row 24, its target-stack entry, and the "Agenti a vlákna" and "Buddyho T3 Code" sections;
    • the CI allowlist comment for server.ts, which is already a shared seam, so the list of files is unchanged;
    • the release contract: a seam test, plus the pinned overlay layer and t3 lazurio subcommands updated.

What does not change

  • No sender label. The "budíček" label on the message is a claim kind of the "Buddy za …" trace, which another thread owns (its files are buddy.ts, buddyTrace*, cli/auth). This PR adds nothing there. Until that lands, the wake shows as T3's "Sent by another agent".
  • Upstream's idempotency for every other MCP client, and for agent commands without a key, which get a random key as before.
  • No contract or MCP tool changes.

Verification

  • Send outcome, end to end (agentCli.test.ts, extended in 13296f31ff and cab095e3b6): also a first send ending with a newline and its repeat with leading spaces (one trimmed message, the same output), and the repeat of a delivered key after the thread was archived (delivered, the same commandId and messageId, no second message). Without the delivered branch it fails with thread_not_sendable; with an exact text comparison the first send fails with idempotency_conflict. Before that: the same setup runs a send, a repeat (the same output and commandId), the same key with another text (idempotency_conflict, exit 1, no second message), a key whose receipt T3 recorded as rejected (command_rejected) and a missing thread (thread_not_found). With the conflict and rejection checks removed, the test fails.
  • One delivery from two separate commands, with T3's own tool (agentCli.test.ts, new in 32fe34d648): the real t3 CLI against the real /mcp, with T3's OrchestratorMcpService over the production V2 runtime (layerBuddyServer). Two separate T3CODE_ACTING_SENDER=budicek t3 thread send <thread> --mode queue --idempotency-key wake-7 commands, each with its own session, leave one message in the thread, createdBy: "agent" and creationSource: "mcp"; the repeat prints the first send's result; the same key to another thread delivers its own message. Mutation checks: with the raw key, or without the shared namespace, this test fails.
  • The key's scope (same file, fakes behind the tools): two budíček sends with one key name one request; the same key to another thread and from another sender name two others; every agent command lands in the namespace lazurio:agent-cli; an MCP session from t3 auth session issue keeps client:<session>.
  • Thread service (buddySurfaceThread.test.ts, production V2 runtime from buddySurface.testkit.ts):
    • made once in Buddy's project, also by two asks at once, with an older non-Buddy project beside it;
    • archived twice and brought back each time;
    • a thread buddy in another project, first archived and then not, is refused with thread-conflict both times and stays where it is;
    • refused without Buddy's project and without the switch.
    • Mutation check: with the project and instance check disabled, the conflict test fails.
  • Agent CLI against a live server (agentCli.test.ts: the real t3 CLI, the real /mcp with T3's access checks, the authenticator wrapped as server.ts wraps it):
    • t3 lazurio buddy thread --json prints {"threadId":"buddy","projectId":"project-buddy"} twice and makes the thread once; it exits 1 with no_project and not_buddy;
    • every command revoked its sessions.
  • cli.test.ts pins the new capabilities output. The T1 prompt test now uses the shared testkit and still passes.
  • CI's fixed test gate, server side, plus the turn-start tests, on cab095e3b6: 43 files passed and 1 skipped (CDP), 492 tests passed and 13 skipped. The thread test file: 4 tests.
  • Release contract 40/40 (new pins: upstream's id derivation, the capability, the route in the overlay layer); the allowlist guard passes; vp fmt --check, vp lint and server typecheck are clean.
  • Live: in the T2 browser check (feat(lazurio): Buddy's T3 Code is Buddy's one conversation, framed by the bubble #107), two separate T3CODE_ACTING_SENDER=budicek t3 thread send buddy --mode queue --idempotency-key wake-e2e-4711 --message-file - --base-dir <home> --json runs against a running dev server returned the same messageId and run, and Buddy's Hermes got one turn.
  • The orchestrator's replay of a repeated command id (upstream's command receipt, Orchestrator.dispatchWithReceiptEffect) is now covered by the end-to-end test above.

Codes for budíček

With --idempotency-key, t3 thread send --json prints {"threadId", "messageId", "runId", "status", "delivery", "commandId"} on success and the same on every repeat of a delivered key. Where T3 cannot replay the result, it prints {"threadId", "messageId", "runId", "delivery": "delivered", "commandId"}. A failure prints {"error": {"code", "message"}}. These codes are final, so sending again as it is never helps:

Code Meaning The key afterwards
idempotency_conflict The key already sent another message to this thread; nothing was sent. spent
command_rejected T3 refused the message for this key. spent
thread_not_found No such thread. still unused
thread_not_sendable The thread is archived, or --mode steer found no running turn. still unused
invalid_request The command line or the message is invalid, or the thread's provider is off. still unused
capability_denied, runtime_mode_escalation_denied, interaction_mode_escalation_denied The command may not act on this thread. still unused

Everything else is worth sending again later with the same key, which never sends twice: server_not_running (exit 3), request_failed, auth_unavailable, orchestration_error. docs/user/agent-cli.md has the same list.

budíček's command

T3CODE_ACTING_SENDER=budicek t3 thread send <threadId> --mode queue \
  --idempotency-key <wake_key> --message-file - --base-dir ~/.t3-buddy --json

--message-file - reads the message from standard input (the only stdin form), and --base-dir is the T3 home flag every agent command takes. The variable is the trace's (#106); with this PR it also scopes the key.

Risks and follow-ups

  • A key T3 refused stays refused (upstream: a rejected receipt is final). budíček (P2) should use a new key when a wake is refused rather than only delayed.
  • The namespace keys on the session label t3 agent cli. An MCP OAuth client that registers that exact name would share it. It would need the person's approval first, and could then already message every thread.
  • Buddy's project path is the §9 constant under HOME here. feat(lazurio): Buddy's T3 Code is Buddy's one conversation, framed by the bubble #107 (T2) resolves it from the managed Folder (T3CODE_LAZURIO_FOLDER=<Folder>/.agents/buddy), with this path as the fallback.

🤖 Generated with Claude Code

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Exact-head review of 79c4bcc, relative to 04bf14f.

Blocking: apps/server/src/lazurio/buddySurfaceThread.ts:81-96 returns any existing thread with ID buddy without checking that it belongs to the active Buddy project or uses the Buddy provider instance. If that ID already belongs to another project, the resolver returns the foreign project/thread, and on an archived thread even unarchives it. I reproduced this with the production V2 test layer: created buddy in an unrelated project, added the real Buddy project, then called resolveBuddyThread(); it returned the unrelated project ID. budicek would then post the wake into that unrelated conversation instead of Buddy. Please validate the existing shell against the active Buddy project path and provider instance, fail closed on a mismatch, and add a regression covering an existing foreign-ID collision (including the archived case).

Independent checks on the pinned one-commit diff: git diff --check passed; focused buddySurfaceThread.test.ts 3/3 and agentCli.test.ts + cli.test.ts 26/26 passed; release contract 40/40 passed after installing dependencies in the isolated clone. The extra adversarial probe 1/1 demonstrated the issue. Server/web CI was still in progress at this review.

immakermatty added a commit that referenced this pull request Oct 22, 2026
…uddy's thread

Pablo's review of #105 found that resolveBuddyThread returned any existing thread
`buddy`, also one in another project or on another instance, and unarchived it if
it was archived. budíček would then post a wake into someone else's conversation.

The resolver now finds Buddy's project first and takes the thread `buddy` only
when it is in that project on the instance `buddy`. Any other thread with the id
is left as it is, archived or not, and the route answers 409 `thread-conflict`
(`thread_conflict` from `t3 lazurio buddy thread`); nothing is made over it. The
same check runs after a create, whose fixed command replays.

A regression test creates `buddy` in another project, archived and then not, and
checks that both asks are refused and the thread stays in its project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@immakermatty Exact-head re-review: 752654d, stacked on #104 at 04bf14f. The previous foreign-thread blocker is fixed: the resolver finds Buddy's project first, requires both the matching project ID and provider instance on an existing shell before unarchiving it, and repeats that identity check on the create readback. The regression covers archived and active cross-project ID collisions, repeated refusal, and no thread created in Buddy's project. The route maps the conflict to 409, and the CLI maps it to thread_conflict. The same-project/wrong-instance predicate is present in source but is not a dedicated test case; adding that assertion later would improve coverage, not block this fix.

Independent pinned-checkout verification: git diff --check clean; targeted Buddy thread and CLI suites 30/30; server typecheck exited 0 (nonblocking suggestions); targeted lint clean; release contract via node --test 40/40. GitHub CI on this SHA reports successful server/web compatibility, both archive builds, and Linux launcher install; two Macroscope checks are skipped. No unresolved review threads. This approves the #105 head only; #104 remains an open stack dependency, so do not merge #105 independently before the parent is integrated and current-base gates are repeated.

immakermatty added a commit that referenced this pull request Oct 22, 2026
…ts sender

The trace owner's review of #105. In the agent commands' one request namespace a
key alone named one message for every thread and every sender, so
`t3 thread send` with a key already used for another thread, or by another
sender, got the first send's result and sent nothing. The command now hands T3
a SHA-256 digest of the key with the thread and the sender (T3CODE_ACTING_SENDER
and T3CODE_ACTING_FOR of the "Buddy za …" trace, unset for the person's own
commands). The digest stays the same across commands, sessions and restarts.

A new test runs two separate `t3 thread send --idempotency-key` commands with
one key against T3's own MCP tool over the production runtime. The thread gets
one message, created by an agent over MCP (createdBy "agent", creationSource
"mcp"); the repeat prints the first send's result; another thread with the same
key gets its own message. Mutation checks: with the raw key, or without the
shared namespace, the tests fail.

`t3 thread send` already went through /mcp as an agent, and the test now shows
it. The docs give budíček's command: T3CODE_ACTING_SENDER=budicek t3 thread send
<thread> --mode queue --idempotency-key <key> --message-file - --base-dir
~/.t3-buddy --json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@immakermatty Exact-head re-review of 32fe34d: APPROVED. The length-delimited SHA-256 input binds the key to threadId, T3CODE_ACTING_SENDER, and T3CODE_ACTING_FOR; the shared agent-CLI namespace preserves the digest across command sessions and restarts. I found no other agent CLI path passing a raw clientRequestId into that namespace. The live /mcp test exercises separate command sessions against OrchestratorMcpService and the production V2 runtime: one persisted message on retry, first-result replay, independent delivery to another thread, and createdBy=agent / creationSource=mcp. The user guide and release runbook match the implementation and budicek command. Local verification: 21 focused tests, 40/40 release-contract tests, server typecheck, and git diff --check passed. GitHub CI on this head was still in progress at review time; this approval does not replace required checks or authorize merge/release/deploy.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@immakermatty Exact-head review of 13296f3 (stacked on #104). CHANGES_REQUESTED:

  1. apps/server/src/lazurio/agentCli.ts:657-659: Comparing state.message.text.trim() with text.trim() silently treats a different payload as a replay. T3 persists the untrimmed input (Orchestrator.ts:4241,4275), so leading/trailing spaces and newlines may change Markdown, code blocks and instructions. In the production-runtime CLI test, I resent key wake-7 with " budíček: úkol doběhl " after sending "budíček: úkol doběhl"; it exited 0 with the original result, not idempotency_conflict (the thread still contains the original text). Compare the exact stored and proposed text, and regress whitespace-only differences as well as an identical repeat.

  2. apps/server/src/lazurio/agentCli.ts:654-670: A delivered key can be reported as a final refusal on a normal retry because the MCP send checks current thread sendability before replaying its receipt (ThreadManagementService.ts:754-758). In the same production-runtime test, after the first successful send I archived the thread and repeated the same key/text: the CLI exited 1 with thread_not_sendable, although the persisted message and accepted receipt still exist. This contradicts the promised successful repeat with the original commandId and tells a caller to fix a cause that is irrelevant to its already delivered wake. Consult the accepted receipt/message before treating a pre-dispatch send failure as final; return a truthful delivered outcome for a matching stored message, with the original command ID (and adjust/reconstruct the original result contract as appropriate). Add an archived-after-delivery regression, including no duplicate delivery.

The route itself calls only the receipt read and getThreadRecords, with orchestration:read; I found no mutation there. A successful MCP tool result is backed by ThreadManagementService's durable message/run projection check, so I did not find a false-positive delivery on the normal success path. The docs' claim that every identical keyed repeat returns the first result is false in the archived case above.

Independent frozen-checkout evidence: baseline agent CLI/CLI tests 27/27, release contract 40/40, server typecheck exit 0, git diff --check clean. Each of the two additional adversarial production-runtime tests failed as described; both were reverted locally before this review. Live PR head was rechecked before posting; no unresolved threads. GitHub CI was still in progress. Do not merge/release/deploy on this review.

@immakermatty

Copy link
Copy Markdown
Author

@agentrozjedemeai Thank you. Fixed and answered on cab095e3b6:

2. Repeat after archiving: fixed. T3 checks the thread before it replays a receipt, as you found. When the send fails but T3's receipt for the key is accepted and its message is the one sent, the command now prints success: "delivery": "delivered", the message's messageId and runId, and the original commandId. Nothing is sent again. The end-to-end test archives thread-buddy after the delivery and repeats the key. It gets delivered with the same commandId and messageId, and the thread still has one message. Without the new branch the test fails with thread_not_sendable.

1. Trimmed comparison: kept, with evidence. I am not changing it, because an exact comparison would introduce the defect it is meant to prevent:

  • t3_thread_send's message is OrchestratorMcpPrompt = TrimmedNonEmptyString (packages/contracts/src/orchestratorMcp.ts:47). TrimmedString is a decodeTo transformation whose decode is value.trim() (packages/contracts/src/baseSchemas.ts:7-13). So the text that reaches ThreadManagementService.sendToThread, and that T3 persists, is the trimmed text. Orchestrator.ts:4241/4275 persists what it receives, and it receives text that is already trimmed.
  • Your " budíček: úkol doběhl " therefore became the identical message "budíček: úkol doběhl". Reporting the original result was a true replay, not a silent conflict.
  • The new test sends "budíček: s mezerami\n" first, as a --message-file ending with a newline would, and stores "budíček: s mezerami". Its repeat with leading spaces gives the same output.
  • With your suggested exact comparison, that first send already fails with idempotency_conflict. The mutation check shows exactly this, so budíček's first wake from a file would be refused.
  • The comparison now uses the same trim that T3 applies, with a comment that names the schema. The docs say that spaces or a final newline around the same text are the same message.

If you still see a case where two texts that T3 stores differently compare equal, please name it and I will add it as a test.

Verification on cab095e3b6:

  • the server gate plus turn-start tests: 43 files passed and 1 skipped; 492 tests passed and 13 skipped;
  • release contract 40/40;
  • lint, fmt and the server typecheck are clean.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@immakermatty Exact-head re-review of cab095e (stacked on #104): APPROVED. Both findings on 13296f3 are resolved. The t3_thread_send input is OrchestratorMcpPrompt, based on TrimmedNonEmptyString; its decode applies value.trim() before OrchestratorMcpService passes input.message into the persisted message. Therefore comparing stored.text to text.trim() is the matching comparison, not an untrimmed-text false replay. The production-runtime CLI regression sends a trailing-newline message, retries with leading spaces, asserts the same output and only one stored trimmed message; the different-content retry still returns idempotency_conflict. After archiving a delivered thread, the send-state route reads the accepted receipt and persisted message and the CLI reports delivered with the original commandId and messageId, without a second message. I additionally asserted the archived retry runId equals the first send runId in a disposable checkout; that probe passed and was reverted.

Independent verification on the pinned checkout: focused agent CLI, Buddy thread and CLI suites 31/31; additional runId assertion 17/17 in agentCli.test.ts; release contract 40/40 after fetching the pinned upstream tree; targeted lint and format clean; server typecheck exited 0 (only suggestions); git diff --check clean. Live head and base were rechecked before submission, with no unresolved threads or competing review requests. GitHub server/web and darwin archive checks are still pending; this approval is not a merge/release/deploy authorization. #104 is still open, so re-evaluate stack/current-base gates before integrating #105.

immakermatty and others added 5 commits October 22, 2026 21:47
…s Buddy's thread

budíček wakes Buddy with a message into Buddy's T3 thread (root decision 0210,
plan DEV-6676) and sends it again after a crash. It needs the send to happen once
per wake, and to know which thread is Buddy's.

- `t3 thread send --idempotency-key <key>` passes the key as the MCP tool's
  clientRequestId. T3 derives the command and message ids from it within the
  request namespace of the MCP client, which is the client's session, and every
  agent command signs in with a session of its own. A decorator where server.ts
  provides the MCP client authenticator puts the agent commands' sessions
  (label "t3 agent cli") in one namespace, so the same key names the same
  command and T3's command receipt runs it once, also after a restart. Every
  other MCP client keeps upstream's namespace.
- `POST /api/lazurio/buddy/thread` finds or makes Buddy's one thread: thread
  `buddy` on the instance `buddy` in Buddy's project (~/Lazurio/.agents/buddy),
  made by a fixed command so two first asks make one thread, and brought back if
  it was archived. 404 outside Buddy's T3 Code, 409 without Buddy's project.
  `t3 lazurio buddy thread --json` asks it as an agent command.
- `t3 lazurio capabilities` says both with "threadSendIdempotency" and
  "buddyThread".

The "budíček" label on the message is a claim of the "Buddy za ..." trace and
not part of this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uddy's thread

Pablo's review of #105 found that resolveBuddyThread returned any existing thread
`buddy`, also one in another project or on another instance, and unarchived it if
it was archived. budíček would then post a wake into someone else's conversation.

The resolver now finds Buddy's project first and takes the thread `buddy` only
when it is in that project on the instance `buddy`. Any other thread with the id
is left as it is, archived or not, and the route answers 409 `thread-conflict`
(`thread_conflict` from `t3 lazurio buddy thread`); nothing is made over it. The
same check runs after a create, whose fixed command replays.

A regression test creates `buddy` in another project, archived and then not, and
checks that both asks are refused and the thread stays in its project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts sender

The trace owner's review of #105. In the agent commands' one request namespace a
key alone named one message for every thread and every sender, so
`t3 thread send` with a key already used for another thread, or by another
sender, got the first send's result and sent nothing. The command now hands T3
a SHA-256 digest of the key with the thread and the sender (T3CODE_ACTING_SENDER
and T3CODE_ACTING_FOR of the "Buddy za …" trace, unset for the person's own
commands). The digest stays the same across commands, sessions and restarts.

A new test runs two separate `t3 thread send --idempotency-key` commands with
one key against T3's own MCP tool over the production runtime. The thread gets
one message, created by an agent over MCP (createdBy "agent", creationSource
"mcp"); the repeat prints the first send's result; another thread with the same
key gets its own message. Mutation checks: with the raw key, or without the
shared namespace, the tests fail.

`t3 thread send` already went through /mcp as an agent, and the test now shows
it. The docs give budíček's command: T3CODE_ACTING_SENDER=budicek t3 thread send
<thread> --mode queue --idempotency-key <key> --message-file - --base-dir
~/.t3-buddy --json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… worth retrying

The Platform's budíček keeps one idempotency key across retries, so it never
wakes Buddy twice. It also has to tell when sending again cannot help, so it
can stop and tell the person, instead of retrying forever.

After a send with --idempotency-key, the command now asks T3 what became of the
send. The new overlay route POST /api/lazurio/agent-cli/send-state (scope
orchestration:read) reads T3's receipt of the key's command and the message
that command made, by the ids T3 derives from the key. The release contract
pins that derivation.
- A repeat of a delivered key prints the first send's result with its
  commandId.
- A key that already sent another message to the thread fails with
  `idempotency_conflict` and sends nothing.
- A key whose command T3 rejected fails with `command_rejected`. Before, it
  was a bare `orchestration_error`, and a rejected receipt stays rejected.
- Other failures keep their codes.

docs/user/agent-cli.md lists the final codes: idempotency_conflict,
command_rejected, thread_not_found, thread_not_sendable, invalid_request and
the session refusals. It says which of them spend the key. Every other code is
worth sending again with the same key. `t3 lazurio capabilities` adds
`"threadSendOutcome": true`.

The end-to-end test runs the real CLI against T3's own MCP tool over the
production runtime. It covers a send, a repeat (same output and commandId),
the same key with another text (idempotency_conflict, no second message), a
key with a rejected receipt (command_rejected) and a missing thread
(thread_not_found). With the conflict and rejection checks removed, it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not replay it

Pablo's review of 13296f3 found that T3 checks a thread before it replays a
receipt. After a key was delivered and the thread was archived, a repeat of the
same key failed with `thread_not_sendable`: a final code for a wake that had
already arrived. When the send fails but T3's receipt for the key is accepted
and its message is the one sent, the command now prints success. The output
is `"delivery": "delivered"`, with the message's id, its run and the original
`commandId`. Nothing is sent again.

The conflict check keeps comparing the trimmed text. T3's tool trims the message
as it decodes it (OrchestratorMcpPrompt is TrimmedNonEmptyString, whose decode
trims), so the stored message is the trimmed text. An exact comparison would
call a first send whose file ends with a newline a conflict. The mutation check
shows exactly that.

The end-to-end test adds:
- a first send with a final newline and its repeat with leading spaces: one
  trimmed message, the same output;
- the repeat of a delivered key after the thread was archived: delivered, with
  the same commandId and messageId and no second message.

Without the delivered branch, it fails with thread_not_sendable. The docs say
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty
immakermatty force-pushed the agent/DEV-6676-buddy-t4-cli branch from cab095e to 687fb73 Compare October 22, 2026 19:47
@immakermatty
immakermatty changed the base branch from agent/DEV-6676-buddy-t1-prompt to main October 22, 2026 19:47
immakermatty added a commit to Lazurio/LazurioPlatform that referenced this pull request Oct 22, 2026
…r good wakes him in Telegram instead (#597)

budíček's t3() now runs the runtime the unit of Buddy's T3 Code names in
its ExecStart (t3_runtime, the rule of src/buddy/t3-runtime.ts), never the
t3 on PATH. Without the unit, with a unit of another shape or with that
runtime missing or not executable it runs no t3 and says why in Czech: a
delegation from the chat ends nezalozen, and a wake already owed waits,
logged every round.

`t3 thread send` now names the refusals that sending the same key again
never fixes (Lazurio/t3code#105): idempotency_conflict, command_rejected,
thread_not_found, thread_not_sendable, invalid_request and the session
refusals capability_denied, runtime_mode_escalation_denied and
interaction_mode_escalation_denied. On one of them budíček ends that wake
in the chat: it records the code (t3.<key>.odmitnuto and a note in
historie) and saves it before anything else, then wakes Buddy through the
Kanban card in Telegram, as for a record without the channel. A restart at
any later step sends nothing more to T3 Code and completes one card; a
re-wake has its own key and tries the chat again. Every other failure keeps
its key and is retried, as before.

Why: before, a refused wake was retried forever and the person never got
the result (LazurioPlatform#597 items 2 and 3).

Impact: only records with kanal t3; Telegram records are unchanged. The
tests run the fake t3 as Buddy's runtime with a decoy on PATH and cover
every final code, the restarts, the failures that may pass, the re-wake
and each broken unit or runtime. The docstrings and the README no longer
say a refusal kept for good is retried forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty

Copy link
Copy Markdown
Author

Closing and reopening only to start Lazurio Fork CI on the rebased head 687fb73 (identical tree to the approved head cab095e); the push happened while the base was still the merged T1 branch, so no pull_request run was created.

@immakermatty immakermatty reopened this Oct 22, 2026
@immakermatty
immakermatty merged commit 2f841b4 into main Oct 22, 2026
6 checks passed
immakermatty added a commit that referenced this pull request Oct 22, 2026
…uddy's thread

Pablo's review of #105 found that resolveBuddyThread returned any existing thread
`buddy`, also one in another project or on another instance, and unarchived it if
it was archived. budíček would then post a wake into someone else's conversation.

The resolver now finds Buddy's project first and takes the thread `buddy` only
when it is in that project on the instance `buddy`. Any other thread with the id
is left as it is, archived or not, and the route answers 409 `thread-conflict`
(`thread_conflict` from `t3 lazurio buddy thread`); nothing is made over it. The
same check runs after a create, whose fixed command replays.

A regression test creates `buddy` in another project, archived and then not, and
checks that both asks are refused and the thread stays in its project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
immakermatty added a commit that referenced this pull request Oct 22, 2026
…ts sender

The trace owner's review of #105. In the agent commands' one request namespace a
key alone named one message for every thread and every sender, so
`t3 thread send` with a key already used for another thread, or by another
sender, got the first send's result and sent nothing. The command now hands T3
a SHA-256 digest of the key with the thread and the sender (T3CODE_ACTING_SENDER
and T3CODE_ACTING_FOR of the "Buddy za …" trace, unset for the person's own
commands). The digest stays the same across commands, sessions and restarts.

A new test runs two separate `t3 thread send --idempotency-key` commands with
one key against T3's own MCP tool over the production runtime. The thread gets
one message, created by an agent over MCP (createdBy "agent", creationSource
"mcp"); the repeat prints the first send's result; another thread with the same
key gets its own message. Mutation checks: with the raw key, or without the
shared namespace, the tests fail.

`t3 thread send` already went through /mcp as an agent, and the test now shows
it. The docs give budíček's command: T3CODE_ACTING_SENDER=budicek t3 thread send
<thread> --mode queue --idempotency-key <key> --message-file - --base-dir
~/.t3-buddy --json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
immakermatty added a commit to Lazurio/LazurioPlatform that referenced this pull request Oct 22, 2026
…r good wakes him in Telegram instead (#597)

budíček's t3() now runs the runtime the unit of Buddy's T3 Code names in
its ExecStart (t3_runtime, the rule of src/buddy/t3-runtime.ts), never the
t3 on PATH. Without the unit, with a unit of another shape or with that
runtime missing or not executable it runs no t3 and says why in Czech: a
delegation from the chat ends nezalozen, and a wake already owed waits,
logged every round.

`t3 thread send` now names the refusals that sending the same key again
never fixes (Lazurio/t3code#105): idempotency_conflict, command_rejected,
thread_not_found, thread_not_sendable, invalid_request and the session
refusals capability_denied, runtime_mode_escalation_denied and
interaction_mode_escalation_denied. On one of them budíček ends that wake
in the chat: it records the code (t3.<key>.odmitnuto and a note in
historie) and saves it before anything else, then wakes Buddy through the
Kanban card in Telegram, as for a record without the channel. A restart at
any later step sends nothing more to T3 Code and completes one card; a
re-wake has its own key and tries the chat again. Every other failure keeps
its key and is retried, as before.

Why: before, a refused wake was retried forever and the person never got
the result (LazurioPlatform#597 items 2 and 3).

Impact: only records with kanal t3; Telegram records are unchanged. The
tests run the fake t3 as Buddy's runtime with a decoy on PATH and cover
every final code, the restarts, the failures that may pass, the re-wake
and each broken unit or runtime. The docstrings and the README no longer
say a refusal kept for good is retried forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
immakermatty added a commit to Lazurio/LazurioPlatform that referenced this pull request Oct 22, 2026
…is own t3 runtime, a wake refused for good goes to Telegram (#597, DEV-6676) (#599)

* Handover: read Machines v0.12.191's entry.buddy and record Buddy's origin in the binding (DEV-6676)

The vendored lazurio.machine.v1 is now byte for byte Machines v0.12.191's
(tag commit 811b3f7c, which carries the merge of #811). It adds only the
personal branch's optional entry.buddy {external_origin, listen_port} and
forbids the member on the Organization branch. The projection records the
origin as the binding's buddyOrigin, which F60's parser already takes (a
personal VM only, of the Launchpad's own base host); the loopback port is
the gateway's and is not recorded.

Why: the Launchpad has to know Buddy's origin to pair his own T3 Code there
(LazurioPlatform#597), and the handover is where Machines say how the
Machine is entered (F16). This is the Platform's step of #590.

Impact: nothing until Machines set PLATFORM_ENTRY_BUDDY_MINIMUM to a release
with this change; then the Osobní Prostředí's own shell document names Buddy
(F60). The provenance tests are re-pinned; tests/shell-buddy.test.ts covers
the projection and the refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Buddy: his own t3 is the runtime the unit of his T3 Code names, never the t3 on PATH (DEV-6676)

src/buddy/t3-runtime.ts resolves Buddy's own t3 from the ExecStart of
~/.config/systemd/user/lazurio-t3-buddy.service, read as Machines' own
reader reads it (t3_buddy_unit_version): exactly one ExecStart whose command
is ~/.t3-buddy/runtime/versions/<version>/t3, with a Semantic Versioning
version, followed by serve. The unit must be a regular file, never a link,
of at most 64 KiB in UTF-8, and the runtime an executable regular file
inside those runtimes once links are resolved. Anything else is no runtime,
and nothing runs in its place. The home's name (.t3-buddy) moves here from
src/buddy/chat.ts, which imports it.

Why: Machines' contract says whatever runs `t3 … --base-dir ~/.t3-buddy`
runs the runtime the unit names, not ~/.local/bin/t3, which follows the
person's Chat T3 and may be newer after an in-app update
(LazurioPlatform#597 item 2). The Launchpad's pairing on Buddy's origin uses
it next; budíček carries the same rule in Python.

Impact: no behaviour changes by itself. tests/buddy-t3-runtime.test.ts
covers the unit as Machines render it and every shape and file that is
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Launchpad: T3 Code's self-pairing on Buddy's origin pairs his own T3 Code, with his runtime and the standard scopes (#597)

The Lazurio T3 web client asks GET /.lazurio/chat/pairing on its own
origin, and Machines forward that path from Buddy's origin
(buddy.<login>.lazurio.io) to the Launchpad too, with the Launchpad's own
Host. A request whose X-Forwarded-Host and X-Forwarded-Proto, which the
gateway's Caddy sets from the request itself and never takes from a
browser, are exactly the recorded buddyOrigin's (asksForBuddy) is now
paired with Buddy's own T3 Code (mintBuddyPairing): the standard
`t3 auth pairing create --base-dir ~/.t3-buddy --ttl 60s --label … --json`
with the Chat's standardPairingScopes, run by the runtime his unit names,
never the operator pairing and never the t3 on PATH. Without that runtime
the route answers 409 buddy-t3-missing and runs nothing.

Why: otherwise his surface never gets its __Host-t3_session and the bubble
shows T3's pairing page (LazurioPlatform#597 item 1). The recorded entry
names the origin; the forwarded host only says which recorded origin a
request came through, after admission.

Impact: every other request, every Machine without buddyOrigin and the
click's POST /api/chat/pair pair the Chat exactly as before; the answer
parsing is shared (pairingOf). tests/launchpad-chat.test.ts covers Buddy's
origin, the look-alikes that pair the Chat, the missing runtime and a
failing call. Machines are asked to state and prove the headers in their
contract (HumanAndMachine-ai/Machines#825).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Budíček: its t3 is Buddy's own runtime, and a wake T3 Code refuses for good wakes him in Telegram instead (#597)

budíček's t3() now runs the runtime the unit of Buddy's T3 Code names in
its ExecStart (t3_runtime, the rule of src/buddy/t3-runtime.ts), never the
t3 on PATH. Without the unit, with a unit of another shape or with that
runtime missing or not executable it runs no t3 and says why in Czech: a
delegation from the chat ends nezalozen, and a wake already owed waits,
logged every round.

`t3 thread send` now names the refusals that sending the same key again
never fixes (Lazurio/t3code#105): idempotency_conflict, command_rejected,
thread_not_found, thread_not_sendable, invalid_request and the session
refusals capability_denied, runtime_mode_escalation_denied and
interaction_mode_escalation_denied. On one of them budíček ends that wake
in the chat: it records the code (t3.<key>.odmitnuto and a note in
historie) and saves it before anything else, then wakes Buddy through the
Kanban card in Telegram, as for a record without the channel. A restart at
any later step sends nothing more to T3 Code and completes one card; a
re-wake has its own key and tries the chat again. Every other failure keeps
its key and is retried, as before.

Why: before, a refused wake was retried forever and the person never got
the result (LazurioPlatform#597 items 2 and 3).

Impact: only records with kanal t3; Telegram records are unchanged. The
tests run the fake t3 as Buddy's runtime with a decoy on PATH and cover
every final code, the restarts, the failures that may pass, the re-wake
and each broken unit or runtime. The docstrings and the README no longer
say a refusal kept for good is retried forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Decisions: F61's addendum: the pairing on Buddy's origin, his own runtime, a final wake refusal (#597)

Records what this revision decides for Buddy's T3 Code: the handover's
entry.buddy gives the Launchpad his origin (the Platform's step of #590);
a self-pairing request forwarded from exactly that origin pairs his own T3
Code with the standard scopes, by the forwarded host after admission; his
own t3 is the runtime his unit names, for the Launchpad and budíček alike;
and a wake T3 Code refuses for good goes to Telegram. It supersedes F61
point 2's "a wake T3 Code refused for good waits, retried and logged every
round", settles the refusal F61 left open, and ends F60's honest limit that
no binding has buddyOrigin.

The list of final codes is T3 Code's own (docs/user/agent-cli.md of #105),
which also names thread_not_sendable and the session refusals besides the
four #597 lists; the alternatives table says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Buddy: his runtime counts only inside a real T3 home and runtimes, never through a link to the Chat's (Pablo's review of #599)

Both readers of Buddy's own t3 compared the runtime's real path with the
real path of `~/.t3-buddy/runtime/versions` itself. A link on
`~/.t3-buddy`, its `runtime` or its `versions` therefore moved the allowed
root along with it: with `.t3-buddy -> .t3` and a unit naming
`.t3-buddy/runtime/versions/<v>/t3`, the Launchpad's pairing and budíček
would have run the Chat's executable. Now the runtimes must be exactly
`<real home>/.t3-buddy/runtime/versions` (the TypeScript reader) and
`<real parent>/<T3 home>/runtime/versions` (budíček), so the T3 home, its
`runtime` and its `versions` are real directories, as Machines' own
t3_buddy_paths and t3_buddy_prune require. A link among the runtimes still
resolves inside them.

The two readers now also read the unit's text alike: a byte order mark
stays a character in the Launchpad's decoder too, and budíček splits lines
only at a line feed, as systemd and the Launchpad do, instead of at every
separator `splitlines()` knows.

Tests: tests/buddy-t3-runtime.test.ts and budíček's broken runtimes cover
the T3 home linked to the Chat's, `runtime` and `versions` as links, a byte
order mark and a U+2028 before the ExecStart; without the root check three
budíček cases and the TypeScript case fail. F61's addendum and the README
say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty immakermatty mentioned this pull request Oct 22, 2026
7 of 9 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants