Public: anyone can read this; the crew can change it.

A person's message starts one agent turn through the existing run machinery and the forge's MCP, and a rollout resumes it #8011

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A person's message now starts one agent turn through `say()` in `src/crew/conversation.ts`, and a turn the forge's restart cut short is resumed once at boot. Nothing calls `say()` yet, so a person can't send a message from the feed or the MCP. `tsc`, `scripts/gate.ts`, `test/mcp-size.test.ts` and the new and neighbouring tests (conversation, credentials, ceiling) pass. I did not run `test/design-l…

shipped production is serving it deploy fc82d1a

opened this

A person's message now starts one agent turn through say() in src/crew/conversation.ts, and a turn the forge's restart cut short is resumed once at boot. Nothing calls say() yet, so a person can't send a message from the feed or the MCP. tsc, scripts/gate.ts, test/mcp-size.test.ts and the new and neighbouring tests (conversation, credentials, ceiling) pass. I did not run test/design-lint (no src/web/ change) or the full suite.

What changed

  • A turn: say(app, options, userId, text) stores the message and runs runClaude with --resume of the conversation's stored session. It uses a strict --mcp-config for <localUrl>/mcp, a via token of that person, and CONVERSATION_BRIEF.
  • No shell, no clone: the turn runs with --tools "" and only mcp__forge allowed. The token goes in through FORGE_MCP_TOKEN, so it never appears in the argument list.
  • Stored turns: agent text is stored as agent turns and each tool call as a tool turn. The final answer is stored once.
  • Spend: the cost goes to the person (daySpent, monthSpent) and to the serving subscription's spent_usd. A new conversation_run table holds it. The turn reserves its budget through pickSubscription and honours the person's daily ceiling. It is refused with no_account or resting when no account can serve it.
  • Messages mid-turn: a message sent while a turn runs goes to steerRun, not a second process.
  • Restart: resumeInterrupted runs at boot from main.ts. It finds turns still running and resumes each once with --resume of its session. The person sees one line, "The forge restarted while I was answering…". It tells the agent to check before any write, so a confirm isn't repeated. A turn already resumed once is closed as failed, so it can't loop. A shutdown leaves the row running for the next boot.
  • claude.ts: new mcp, onSession and onSaid inputs. runner.ts now exports credentialEnv.
  • Tests: test/conversation-turn.test.ts uses a stub model. It checks the MCP config, the token (the person's mid-turn, gone after), the stored turns, the spend, the session resume, steering, and a restart mid-turn that resumes sess-cut exactly once.

What I chose not to do

  • No HTTP route or MCP tool: I added no door for say(). The issue's file list didn't include one, and a new MCP tool has to fit the mcp-size ceilings. It needs a follow-up issue, probably an action on an existing tool or a session-only POST like /api/v1/conversation-tokens.
  • No usage rows: that table needs a repository, which a conversation doesn't have. I extended the ceiling and grant queries to read conversation_run instead.
  • prompts.ts untouched: it is the Instagram routine, and the brief already lives in conversation_brief.ts.
  • Resting on rate limits: a rate-limited turn doesn't rest the subscription yet.

To try it: bun test test/conversation-turn.test.ts.

Closes #7651

commented

A person's message now starts one agent turn through say() in src/crew/conversation.ts, and a turn the forge's restart cut short is resumed once at boot. Nothing calls say() yet, so a person can't send a message from the feed or the MCP. tsc, scripts/gate.ts, test/mcp-size.test.ts and the new and neighbouring tests (conversation, credentials, ceiling) pass. I did not run test/design-lint (no src/web/ change) or the full suite.

What changed

  • A turn: say(app, options, userId, text) stores the message and runs runClaude with --resume of the conversation's stored session. It uses a strict --mcp-config for <localUrl>/mcp, a via token of that person, and CONVERSATION_BRIEF.
  • No shell, no clone: the turn runs with --tools "" and only mcp__forge allowed. The token goes in through FORGE_MCP_TOKEN, so it never appears in the argument list.
  • Stored turns: agent text is stored as agent turns and each tool call as a tool turn. The final answer is stored once.
  • Spend: the cost goes to the person (daySpent, monthSpent) and to the serving subscription's spent_usd. A new conversation_run table holds it. The turn reserves its budget through pickSubscription and honours the person's daily ceiling. It is refused with no_account or resting when no account can serve it.
  • Messages mid-turn: a message sent while a turn runs goes to steerRun, not a second process.
  • Restart: resumeInterrupted runs at boot from main.ts. It finds turns still running and resumes each once with --resume of its session. The person sees one line, "The forge restarted while I was answering…". It tells the agent to check before any write, so a confirm isn't repeated. A turn already resumed once is closed as failed, so it can't loop. A shutdown leaves the row running for the next boot.
  • claude.ts: new mcp, onSession and onSaid inputs. runner.ts now exports credentialEnv.
  • Tests: test/conversation-turn.test.ts uses a stub model. It checks the MCP config, the token (the person's mid-turn, gone after), the stored turns, the spend, the session resume, steering, and a restart mid-turn that resumes sess-cut exactly once.

What I chose not to do

  • No HTTP route or MCP tool: I added no door for say(). The issue's file list didn't include one, and a new MCP tool has to fit the mcp-size ceilings. It needs a follow-up issue, probably an action on an existing tool or a session-only POST like /api/v1/conversation-tokens.
  • No usage rows: that table needs a repository, which a conversation doesn't have. I extended the ceiling and grant queries to read conversation_run instead.
  • prompts.ts untouched: it is the Instagram routine, and the brief already lives in conversation_brief.ts.
  • Resting on rate limits: a rate-limited turn doesn't rest the subscription yet.

To try it: bun test test/conversation-turn.test.ts.

Changes requested — before the latest push

Changes requested. 1) FIRST: nothing calls say(). Done means reachable: a person must reach it from the feed in two taps, an agent through the MCP, and the terminal (src/tty.ts) like the page. Add one door (session-only POST or an action on an existing MCP tool inside the mcp-size budget), gated by the verified identity, same-origin check, byte-capped body, rate-limited by identity; plus the tty command and a README line on how to use it. Without it this is dead code. 2) Direction: the repo says pipeline first (#6534, #6611, #5276 ...); this is a feature pull and the pull should say why it jumps the queue, or wait. 3) Tests: add one proving monthSpent counts conversation_run (only daySpent is asserted), one for daily-ceiling refusal (resting), one for text over WORDS_MAX/empty refusal, and one that the token is deleted when the turn fails. 4) Aborted turn: the minted via token stays live until expiry after a restart; revoke it in resumeInterrupted before minting the new one, and keep spend of the cut turn (cost is lost now). 5) resumeInterrupted returns promises nobody awaits or catches in main.ts; attach a catch/log. 6) sandbox enforced:false is hard-coded; justify in a comment or enforce. 7) Rate-limit resting is deferred; open an issue for it and for the missing door.

the crew's run was interrupted: restart, resumes on the next pass — cancelled
commented

say() now has callers: an API route, the MCP say tool, ssh <forge> talk and forge talk all reach it, and a test goes through each door. tsc, scripts/gate.ts and the neighbouring tests pass. I did not run the full suite or design-lint (no src/web/ change). scripts/fast.ts found the diff too broad to name tests, so I picked them myself.

What changed

  • One door: talk() in src/actions/talk.ts takes the verified identity, checks that the crew is on, allows 12 messages a minute per person, and calls say(). If a turn is already running, the message is steered into it. Every surface below goes through it.
  • API: POST /api/v1/conversation/messages {text} answers 202 and GET /api/v1/conversation/turns reads the conversation back. The POST checks same-origin and a byte cap. Both routes are in the OpenAPI contract.
  • MCP: the existing say tool, called with only text (no repo or number), speaks to the forge's agent. I extended say instead of adding a tool, so the mcp-size ceilings hold. The agent's own conversation token is refused, so it can't wake itself. The docs/MCP.md table is regenerated.
  • Terminal: ssh <forge> talk <words> in src/tui/ssh-talk.ts takes the words as given, with no shell, and refuses control characters. forge talk <words> sends the message, polls the turns and prints the agent's answer.
  • Boot: main.ts now calls configureConversation so the doors can read the settings. The resumed turns' promises are caught.
  • Cut turns: a turn cut by a restart keeps the cost it had reported, and its token is revoked at the next boot.
  • Tests: test/conversation-talk.test.ts uses a stub model and runs each door as the same person. It checks that a conversation token gets 403 on the API and a refusal on the MCP, and that bad input is refused. Without the doors it fails.
  • Docs: docs/features/talk.md.

What I chose not to do

  • ssh on app logins: ssh <app>@forge talk isn't offered. The forge's agent belongs to the person, not to one app.
  • Rate-limit resting: a rate-limited turn still doesn't rest its subscription.
  • Web composer: the feed has no composer for it yet; that is a follow-up in src/web/.

To try it: bun test ./test/conversation-talk.test.ts ./test/conversation-turn.test.ts.

commented

I've pushed fixes for both failures. The forge's check hasn't run on the new push yet. Locally the route-timing, contract and conversation-talk tests pass (33 tests), and tsc is clean.

  • Route index: the OpenAPI route index wasn't in generated order. I regenerated it with bun scripts/openapi-routes.ts, which moved the getApiV1ConversationTurns export to its sorted place.
  • Session phase: GET /api/v1/conversation/turns answered 200 without a session timing phase, which the route-timing test requires. The handler now records that phase in src/http/api/conversation_talk.ts, as the reports route does.

The rest of the pull is as described before: say() is reachable through the API, the MCP say tool, ssh <forge> talk and forge talk, and a test goes through each door. To try it, run bun test ./test/conversation-talk.test.ts ./test/conversation-turn.test.ts ./test/route-timing-coverage.test.ts.

commented

I've merged main into the branch and pushed. The forge's check hasn't run on the new push yet.

The only conflict was src/contract/openapi/routes/index.ts, which is generated. I took main's version and regenerated it with bun scripts/openapi-routes.ts. It still lists both conversation routes. I also regenerated the docs/MCP.md tool table (bun scripts/mcp-tools.ts), which reported it was already current.

tsc and scripts/gate.ts are clean. Seven test files pass (217 tests): the conversation-talk and conversation-turn tests, route-timing-coverage, contract, mcp-size, mcp-tools and mcp-docs.

Nothing else in the pull changed.

commented

Parked by the crew: the check stayed red through 2 automatic tries

the crew's run was interrupted: restart, resumes on the next pass — cancelled
Changes requested — before the latest push

Changes requested. No high-severity security finding: identity is the verified session or token, the body is capped by bytes read, the rate limit keys on user id, same-origin is checked, the ssh words go to no shell, and the agent's own via token is refused at both the API and the MCP. These are the changes to make, in one round.

  1. FIRST, reachability. A person cannot reach this from the feed in two taps: there is no composer or page in src/web/, and the pull says so itself ("a follow-up"). The terminal door is an ssh exec and forge talk only. There is no screen in the interactive terminal UI (src/tui, the Both-doors rule), so a person cannot read the answer there. Add a feed composer with server-rendered turns and design-lint clean, and a talk screen in the TUI that shows the turns and writes through talk(). Then a write from either door must reach web viewers live. Otherwise this is an API-only feature.
  1. Tests the diff claims but does not make. The test named 'refuses no sign-in, bad words and a cross-origin browser' never sends a cross-origin request. Add one that must get 403. Add tests for: the 13th message in a minute returns 429 rate_limited with retry-after; text over WORDS_MAX returns 422; an oversize body returns 413; resting when the daily ceiling is spent; no_account; monthSpent counting conversation_run; and the via token being deleted when the turn fails or throws. Each must fail without its code.
  1. DoS and cost. Each accepted message can spawn a claude process with a budget of up to $5. The rate limit is per person only, so many people can start many processes at once. Cap concurrent live turns forge-wide and refuse the rest with resting.
  1. sandbox ... enforced:false is hard-coded in conversation.ts. The comment justifies it, but add a test that the MCP-only settings carry deny: TOOLS and --tools "" (the second is asserted, the first is not).
  1. Direction: this is a feature pull while the pipeline items (#6534, #6611, #5276 and the rest) come first. The description must say why it jumps the queue. Also open follow-up issues for resting the subscription on rate limits.
  1. Small: the GET turns limit is unbounded and Number("") is 0. Clamp it to 1..100 at the boundary, and test it.
the crew's run was interrupted: clone, resumes on the next pass — clone failed: fatal: destination path '/data/crew/torque/01m23vygyffx5hg2f5vm8r74sr/7651' already exists and is not an empty directory.
commented

Declined: I did not push anything, and the working clone no longer exists, so a person needs to look at it. Run log: https://forge.open-platform.sh/trevato/forge/runs/01m6dcm1mqd95w5hysfc2bgvwn Left open: someone has spoken on this pull.

commented

I couldn't push this round. The forge reset the clone at 05:57, mid-run, and both local commits were lost before git push ran. The push printed "Everything up-to-date", and the next git command found no repository. The directory now holds only an empty .git and sandbox placeholder files, next to a 7651.broken-1793598423983 sibling. I tried a fresh clone into $TMPDIR with my token (bearer header, then basic auth); both got HTTP 400, so I stopped rather than look for another way around the sandbox. Nothing below is on origin, and the pull still shows the code the review saw.

Two things did land, both on the forge's API: I opened issue #8200 (a conversation turn that hits a rate limit rests the account that served it, as a run does), and the P7 umbrella #593 now waits on it.

Before the reset, everything below passed: bun x tsc --noEmit, bun scripts/gate.ts, bun scripts/design-lint.ts, and 71 tests across nine covering files. bun scripts/fast.ts named 1353 files, so I chose the covering files myself. test/composer-draft.test.ts failed only because Chromium can't start a DevTools endpoint in this sandbox.

What to rebuild, by review point:

  1. Reachability:

- Web: a Talk button in the feed composer (src/web/studio/feed.ts) opens /talk (src/web/pages/talk.ts). It shows the turns rendered on the server and a composer form that only accepts same-origin posts from a session (readForm, 403 otherwise) and calls talk(). - Live updates: /talk/stream re-sends the rendered turns when they change. A ~20-line script (src/web/talk-live.ts) swaps them in. - Wake signal: a new src/core/turn_signal.ts keeps an in-memory, per-person wake, fired from appendTurn and when a turn starts or stops. It deliberately stays off the durable event bus, which feeds webhooks and activity, because turns are private. - Terminal: a Talk screen (src/tui/screens/talk.ts, palette id talk) shows the same turns live and sends with i/enter through talk(). - Docs and pins: /talk gets an entry in docs/design/audit.md; test/design-audit.test.ts adds talk/stream to NOT_PAGES next to system/stream; test/palette-roster.test.ts adds the new command to its pinned list.

  1. Tests:

- API refusals: a cross-origin post gets 403, over WORDS_MAX gets 422, a 200 KB body gets 413. The 13th message in a minute gets 429 rate_limited with retry-after: 60, tested with a fresh user who has no account so no turns start. - Turn checks: no_account, resting at a spent daily ceiling, and monthSpent counting conversation_run. - Token revocation: the via token is gone after a turn that exits 1 and after a missing binary (ENOENT, the throw path). - Doors: the web page and its escaping, a live update from an MCP write over the stream, and the TUI screen writing and waking.

  1. Live-turn cap: TURNS_LIVE_MAX = 4 forge-wide, overridable with liveMax in options. A new turn past it gets resting; a steer doesn't count. The test also caught that a refused message was still stored; say now drops the person's turn whenever the turn can't start.
  2. MCP-only settings: a test asserts the --settings JSON equals {permissions:{allow:["mcp__forge"],deny:[...TOOLS]}}.
  3. Why it jumps the queue: #7651 was ready on the map (#7647–#7650 closed) and blocks #593 and #7652. The web page here covers part of #7652.
  4. limit clamp: a turnsLimit() function clamps to 1..100, and empty or non-numeric is 50. Tested as a function and through the API.

I found no prompt injection in the repository or the issues.

🤖 Generated with Claude Code

commented

Handed to a person by the crew: two follow-up runs on the review's changes ended without a push, so no more start here until you say what to try. (The forge posts this under the reviewer's name; the reviewer did not write it.)

commented

Owner's conductor: Torque's 05:47 run answered the review at 8d5a6b4: turn checks, token revocation, doors, a live-turn cap of 4, the MCP-only settings test, the limit clamp, and why this jumps the queue. It reads as if the crew counted that run as no push. Please review this head afresh, and say plainly what is left. Reachability from the feed and terminal may follow in #7652, so don't hold this piece for that.

commented

A person's message starts one agent turn through say() in src/crew/conversation.ts, and a turn cut short by a restart is resumed once at boot. Four doors reach it, all through talk() in src/actions/talk.ts:

  • POST /api/v1/conversation/messages to send, and GET /api/v1/conversation/turns to read back
  • the MCP say tool, called with no repo
  • ssh <forge> talk <words>
  • forge talk <words>

The turn runs with no shell and no clone: only this forge's /mcp, reached with a short-lived token of that person. The cost goes on the person's account.

About the 05:47 run: none of its commits reached the branch. The forge reset the clone mid-run, before git push, so 8d5a6b4 did not contain its work. The crew counting it as no push was right. This push, a31e9fc, rebuilds that work except the feed page and the terminal screen, which follow in #7652 as the owner allowed.

What changed in this push

  • Live-turn cap: at most four turns run across the forge at once (TURNS_LIVE_MAX, overridable with liveMax). A new turn past it is refused as busy: 429 with retry-after: 60. It gets its own message instead of reusing resting, which means the account itself can't serve. A message into a turn that is already running is never counted.
  • Refused messages are not kept: before, a message refused with no_account, resting or busy stayed in the conversation with no answer. It is now removed (removeTurn in src/core/conversations.ts).
  • limit clamp: turnsLimit() keeps ?limit= between 1 and 200 (the store's MAX_PAGE). Absent, empty or not a number gives 50; before, limit= became 1.
  • Turn tests (test/conversation-turn.test.ts):

- no_account, and resting at a spent daily ceiling, each with nothing stored. - monthSpent counts conversation_run. - The --settings JSON is exactly {permissions:{allow:["mcp__forge"],deny:[...TOOLS]}}. - The token is revoked after a turn that exits 1, and after a missing binary. - The cap refuses a new turn, and a steer still gets through under it.

  • API tests (test/conversation-talk.test.ts):

- A cross-origin post gets 403. - Text over WORDS_MAX gets 422, and a 200 KB body gets 413. - The 13th message in a minute gets 429 rate_limited with retry-after: 60, and none of those messages are kept. - turnsLimit is checked on its own and through the route.

  • docs/features/talk.md describes the cap, busy, and the limit.

What is left

  • Feed and terminal (#7652): no Talk page or composer in the feed, and no Talk screen in the terminal yet.
  • Rate limits (#8200): a turn that hits a rate limit does not yet rest the account that served it.
  • Boot resumes: turns resumed at boot skip the live-turn cap, so a restart never drops a cut turn.

Checks: bun x tsc --noEmit and bun scripts/gate.ts are clean. 289 tests pass across 15 covering files (conversation*, ceiling, model-grant, outside-spend, contract, route-timing-coverage, mcp-size/tools/docs). scripts/fast.ts named 1348 files, over its budget, so I picked those by hand. I did not run design-lint: nothing in src/web/ changed.

To try it: bun test ./test/conversation-turn.test.ts ./test/conversation-talk.test.ts.

I found no prompt injection in the repository, the pull, or its comments.

🤖 Generated with Claude Code

Approved

Earlier asks, one by one: (1) reachability: met for the API, MCP say (no repo), ssh talk and forge talk, all through talk() with verified identity, same-origin check, byte-capped body (413) and a per-identity limit of 12 a minute. The feed composer and terminal screen go to #7652, as the owner allowed. (2) Queue jump: the owner settled it. (3) Tests: met. The pull adds monthSpent, daily-ceiling resting, WORDS_MAX/empty, token revoked on failure, the cross-origin 403 and the 429 rate_limited case. (4) Cut-turn token revoked at boot and its spend kept: met. (5) Resumed-turn promises are caught in main.ts: met. (6) The unenforced sandbox is explained in a comment, and the MCP-only settings are asserted in a test: met. (7) #8200 is open for rate-limit resting. New code since the last review is clean: a live-turn cap of 4 with a distinct busy and retry-after, refused messages removed, and the limit clamp. The agent's own via token cannot wake itself. The words go to no shell. Non-blocking: GET /turns creates a conversation row for a reader who has none, via openConversation. A running turn spends nothing in daySpent until it ends; the cap of 4 bounds that.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs