Tessera inside Claude Code: messages from the Tessera console reach this session even while it is idle, /tessera sets away mode from the terminal, and the…

Tessera is the control plane for fleets of AI coding agents. Every Claude Code session in your organisation shows up on one live wall: its model, plan, agents, context window and cost. When one stops to ask, answer it from the console or from Slack. When one overspends, Tessera denies its tool calls until the budget resets.
Self-hosted on PostgreSQL. Nothing leaves a session except what you turn on. Apache-2.0.
| Part | What it does |
|---|---|
apps/server | Fastify API: sign-in, organisations and roles, device enrolment, telemetry ingest, live fleet state with a Server-Sent-Events stream, history, budgets with enforcement, model pricing, Slack and webhook notifications, hash-chained audit log. PostgreSQL for everything; Redis optional for several replicas. |
apps/web | The console: landing page, live wall, session pages with a respond panel, history, spend, devices, people and teams, budgets, prices, notifications, audit. React 19, Tailwind 4, Framer Motion. |
packages/contract | The typed telemetry schema (zod), price table, redaction and the view types shared by server, web and reporters. |
packages/cli | The tessera command line, over the public API. Served by the server at /downloads/tessera-cli.tgz. |
deploy/helm/tessera | Helm chart for Kubernetes. |
e2e | Browser end-to-end tests (Playwright) against a running server. |
packages/reporter-claude | The Claude Code reporter, driven by Claude Code's hooks. Reads token usage from the session transcript, answers permission prompts from the console, delivers messages, enforces budgets. |
packages/mod-claude | The Tessera mod for Claude Code 2.1.287 and later, installed beside the hooks: delivers a message from the console to a session idle at its prompt, adds /tessera (connection, away mode) and an away-mode band above the prompt, and hands the reporter Claude Code's own context and cost figures. |
render.yaml creates the web service from the Dockerfile and a PostgreSQL database, generates SECRET_KEY, and uses the service's onrender.com address as PUBLIC_URL. Open the address when the deploy finishes and create your organisation. On the free plan the service sleeps after 15 idle minutes and the database is deleted after 30 days.
cp .env.example .env # set SECRET_KEY and PUBLIC_URL at least
docker compose up -d
open http://localhost:4700 # create your organisation on the landing page
Set BOOTSTRAP_ORG_NAME, BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD to create the first organisation at start instead of through the sign-up page.
helm install tessera deploy/helm/tessera \
--set publicUrl=https://tessera.example.com \
--set secret.databaseUrl='postgres://…/tessera?sslmode=require' \
--set secret.secretKey=$(openssl rand -hex 32) \
--set ingress.enabled=true --set ingress.host=tessera.example.com
PostgreSQL is not bundled; use your managed database. For more than one replica (or autoscaling) set redis.url: replicas share the live fleet and the browser stream through Redis, so a report to one replica and an answer on another meet. The chart runs the container read-only as a non-root user, sets ingress annotations for the long-lived stream, and refuses to render a multi-replica install without Redis. Prefer existingSecret (keys DATABASE_URL, SECRET_KEY, optionally REDIS_URL, METRICS_TOKEN) over values on the command line.
Requires Node 22, pnpm 10 and a PostgreSQL 15+ database.
pnpm install
pnpm --filter @tessera/contract build
export DATABASE_URL=postgres://REDACTED:tessera@localhost:5432/tessera
export SECRET_KEY=$(openssl rand -hex 32)
pnpm dev # server on :4700, Vite console on :5173 (proxies /api)
Production from source: pnpm build then pnpm start; the server serves the built console itself.
One line on the machine that runs Claude Code. Every Tessera server serves its own installer with its address filled in, and the reporter that matches its version.
sh # macOS and Linux curl -fsSL https://tessera.example.com/install.sh | sh -s -- --code XXXX-XXXX ` `powershell # Windows (PowerShell) $env:TESSERA_CODE = "XXXX-XXXX"; irm https://tessera.example.com/install.ps1 | iex ` The script downloads the reporter into ~/.tessera/bin, enrols the machine, adds Tessera's hooks to Claude Code's ~/.claude/settings.json without touching anything else there, and runs a self-check (doctor) that says what to fix if anything is off. Hooks are registered in Claude Code's exec form (a Node executable plus arguments, no shell), which behaves the same on macOS, Linux and Windows. If Node 20 or newer is not installed, the script fetches a private copy into ~/.tessera/node; nothing else on the machine changes. Running the line again is safe: an enrolled machine keeps its token and the hooks are refreshed. On Claude Code 2.1.287 or later it also installs the Tessera mod from a plugin marketplace directory at ~/.tessera/claude-plugins (registered in the same settings.json under extraKnownMarketplaces and enabledPlugins; tessera-reporter install --no-mod skips it). The mod runs inside Claude Code with your permissions, reads the device token from ~/.tessera/config.json and talks only to your Tessera server; claude plugin validate ~/.tessera/claude-plugins/tessera` lists every call it makes. Where an organisation's managed settings allow only its own mods, the hooks work as before without it.Afterwards, the command the installer prints at the end runs doctor (checks Node, enrolment, the server, the token and the hooks), status, or uninstall (removes the hooks; the device token stays until you delete ~/.tessera/config.json).
For CI runners, issue a token in Devices → Issue a token by hand and run the installer with --token tsr_… (or $env:TESSERA_TOKEN on Windows) instead of a code. With Node already installed, the manual route still works: npx -y --package=$TESSERA/downloads/tessera-reporter.tgz tessera-reporter enrol --server $TESSERA --code XXXX-XXXX, then … install.
Only to your server, only with the device token, and only this: the session id, working directory, git repo, branch and dirty-file count, model, token counts per model (read from the transcript), context fill (Claude Code's own figure when the mod runs, kept per minute for the context timeline), compactions, the plan state, each subagent's type, status, tokens and tool-call counts, the first 400 characters of an agent's task and answer, short event lines, the last 40 tool calls as one-line summaries with their outcome and duration, and, while a prompt is open, what is being asked in full: the whole command, an edit's before and after text, the first part of a file about to be written, the URL, the agent's last message before asking, and the allow rules Claude Code suggests. Keys, tokens, passwords and high-entropy strings are replaced with [redacted] before anything is sent. Transcripts and model thinking are never sent. Tool output is sent only where an owner turns on Keep command output (Data and compliance): then the last 40 lines a command printed, and a failed call's whole error, redacted on the machine and sent once. Each call also carries its whole command, its description, path or pattern, and the lines an edit adds and removes. Keep edit history, also off by default, keeps each edit's and write's patch (up to 20,000 characters, redacted, sent once) so changes stay viewable after they are committed. Every tool call is kept for the events retention period, so the session page pages through the whole session, not only the last 40 calls. Beyond the open prompt's own edit, one thing is read from disk, and only when a person asks: a session's uncommitted diff. Someone who can act on the session (its owner, an approver on their team, or an admin) opens a changed file in the console; the request reaches the machine with the next report (the keep-alive's, within about 10 s); the reporter runs git diff HEAD for the 40 biggest changes (a new file is sent as its first 256 KB), redacts it the same way, caps it at 20,000 characters a file and 200,000 in all, and sends it. The server applies the organisation's DLP and holds it in memory for 15 minutes, never in the database; every request is in the audit log (session.diff). An organisation set to metadata only, or a machine with "excerpts": false in ~/.tessera/config.json, sends no diffs.
On a network whose web filter blocks a report's content (a 403 with an HTML page rather than Tessera's JSON), the reporter sends the report again with free-text detail withheld (tool names without arguments, no command, diff or excerpt), so the session still shows with its state, cost and timing, marked detail withheld. Its keep-alive then finds which lines the filter objects to (usually one command, such as a curl with $(…) in it) by sending halves of them, at most 24 requests every two minutes, and from then on withholds only those, marked on their row; the rest of the session is shown in full. doctor detects such a filter and says what to ask your network team for. Request timeouts follow the measured round trip to your server, so a slow proxy does not make hooks fail.
Tessera never connects to an engineer's machine. The reporter posts snapshots and, in its reply, receives any deliveries queued for the session:
PermissionRequest hook) is shown on the wall with the actual change or command, risk flags (deletes files, rewrites history, changes infrastructure or a database, runs downloaded code, touches secrets, writes outside the project, and so on), the agent's own reason, the policy rule that forced the prompt if one did, and the tool calls just before it. The hook waits up to 55 s for an answer from the console, then falls back to the terminal dialog. An answer from the console is returned to Claude Code as the hook's decision. Allow for the rest of this session also returns a session-scoped permission rule (updatedPermissions, destination session), so the agent stops asking about that pattern until the session ends; nothing is written to settings files.AskUserQuestion, caught in the PreToolUse hook) is shown with its options; an answer from the console within 20 s is returned as the tool's answers, so the terminal never shows the dialog. After that the terminal asks as usual.ExitPlanMode) works the same way: approve or send back with feedback.Stop hook), as an instruction to continue with. With the Tessera mod (Claude Code 2.1.287 or later), a session idle at its prompt gets it at once as a new prompt, marked as sent by the tessera plugin; without the mod it waits for that session's next turn. The console says which applies before you send. Whichever hook's report collects a message or an answer keeps it for the hook it is meant for, so none is lost when a tool hook happens to report first./tessera away, /tessera desk and /tessera back in the terminal with the mod): At my desk hands prompts to the terminal after ~10 s, Away holds them up to 10 min for the console or a phone notification. With the mod, a band above the prompt says when away mode is on, with an I'm back button. A prompt shows a countdown while it can be answered here and says plainly once the terminal has it. Re-run the installer on each machine to get the longer hook timeouts.PreToolUse hook) with a reason the engineer sees in the terminal.continue: false), handing control back to the engineer. It applies once.Every figure with a ? beside it says how it is worked out (cost, tokens, context, cache share, waits, the change on the period before), on hover, keyboard focus or a tap. Every session has a Replay tab: a scrubber over its whole run with cost, prompts waiting on people, agents, failures and policy decisions laid on it, and the session's state at the playhead. The playhead is in the URL (?tab=replay&t=754, seconds from the start), so Copy link shares a moment, and a click on a failure, a prompt, a policy denial or a compaction on the overview's timeline opens the replay there. The timeline also draws how full the context window was minute by minute, with the 80% and 90% lines and each compaction. An ended session opens with a summary card: how it ended and after how long, what it left behind (files changed, commits to push), cost and cache share, agents and failures, time spent waiting on people, plan progress and the most used tools. Each tool call in Activity opens to its whole command, why it ran, lines changed, and its output where kept; Show the whole session's tool calls loads the rest of the history. Change history lists every kept edit, file by file, in order, even after a commit or the end of the session. Files changed opens each file to its diff, fetched from the machine when asked (see what leaves the machine, above). A session started in one folder and told to work on another repo (by cd or absolute paths) is followed there: its writes and leading cds move it, reads do not, and the timeline notes each move. One that worked in several shows Worked in, each repository with its branch, its own uncommitted files and their diffs; it stays one session, since it is one Claude Code conversation with one bill. Compare puts two sessions side by side (cost, time, tokens, agents, failures, waits, tool calls); pick two in History or start from a session.
Only the session's owner, an approver on their team, or an admin can act; every action is written to the audit log.
Any runtime that exports OpenTelemetry traces with the GenAI semantic conventions can report without Tessera code on the machine. Issue a device token, then:
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=$TESSERA/api/ingest/otlp/v1/traces
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer tsr_…"
invoke_agent spans become agents (nested by span parentage), chat spans carry gen_ai.usage.* tokens per model, execute_tool spans count tool calls and failures; session.id (or gen_ai.conversation.id, else the trace) groups spans into a session, and vcs.* attributes fill in the repository and branch. Gzip is accepted; protobuf is not. Traces are watch-only: answering, pausing and policy need a first-party reporter.
Owners connect an OpenID Connect provider under Single sign-on: Okta, Microsoft Entra ID, Google Workspace, or any OIDC provider. People type their work email on the sign-in page and are sent to the provider when their domain uses SSO. Options: create accounts on first sign-in with a default role, and require SSO (members lose password sign-in; owners keep theirs as a way back in).
Tessera uses the authorization code flow with PKCE and validates ID tokens against the provider's published keys. A provider can only sign in members of its own organisation, and once someone has signed in through SSO, only that provider identity signs them in again. Public email domains cannot be claimed.
Each email domain must be verified with a DNS TXT record (_tessera-verify.<domain>) before sign-ins for it go to your provider or accounts are created for it; only one organisation can verify a domain.
SCIM 2.0 provisioning. Generate a SCIM token under Single sign-on and point Okta or Entra ID at https://<your Tessera>/scim/v2. The provider creates people, keeps names in step, and deactivates them when they leave (their browser sessions end at once; devices and API keys stop with them). Groups become teams. Roles stay a Tessera decision, and owners cannot be deactivated through SCIM.
To try it locally without an Okta or Entra account, run the development provider and use issuer http://127.0.0.1:4800, client ID tessera-dev, client secret dev-secret:
pnpm --filter @tessera/dev-idp start
It signs in whoever you type, so it is for development only.
Analytics shows estimated spend per day with a forecast for the month, unusual days marked, and spend grouped the way finance asks for it: by team, cost centre (set per team), person, repository, ticket (found in branch names with a pattern you set, PAY-142 in feat/PAY-142-refunds by default) or any tag. Engineers tag sessions when they start Claude Code:
TESSERA_TAGS="project=apollo,ticket=PAY-142" claude
It also shows the share of prompts answered in Tessera (and from Slack), the mean wait for an answer, agent failure rate and context blowouts. Admins download chargeback CSV per group or per session with every allocation column. Cost per session per UTC day is rolled up from the per-minute samples, so spend history outlives the samples' retention.
Each group shows its change against the period of the same length just before (▲ amber when spend rose, ▼ teal when it fell, new when there was none). A team or a person opens their own page: spend per day against the period before, the repositories it went on, how long their prompts waited and how quickly they answered other people's, what they have live now and their recent sessions, and for a team each member's share. Names on People and teams, on a session and in the search palette (⌘K) lead there too. Analytics opens on a given period from a link (/app/analytics?from=2026-10-12&to=2026-10-18, with &team= and &groupBy=).
Under Policies, admins write rules checked before every tool call on every enrolled machine. The first rule that matches decides:
Rules match on tool (Bash, Edit, mcp__github__*), command (terraform apply*), path (/.env, infra/prod/), URL, model, repository, branch and team, all as globs. Any rule can run as a dry run first, recording who it would have stopped. The page tests a tool call against the rules as you edit them, lists recent decisions, and keeps every saved version so you can roll back. Templates cover the usual starting points: Terraform, secrets files, pushes to main, destructive shell commands, production MCP tools.
Under Notifications, an admin adds channels for the events that matter: a session waiting on someone, an agent failing, a context window overflowing, a budget limit, a policy denial, a session going silent, and two made from the daily spend:
A channel's events can be changed in place. Email is not a channel: Tessera has no mail server to send from; a webhook to your mail relay does the job.
| Channel | What it does |
|---|---|
| Slack app | Posts with Allow/Deny on permission prompts and Approve plan/Send back… on plans. The answer is made as the person who pressed, matched by their Slack email, under the same rules as the console, and audited. The message updates to say who answered. |
| Slack webhook, Microsoft Teams | A message or adaptive card with a link to the session. |
| PagerDuty | Opens an incident (Events API v2) and resolves it when the session moves on. |
| Webhook | JSON to any https endpoint, signed: X-Tessera-Signature: t=<unix>,v1=<HMAC-SHA256 of "<t>.<body>">. |
Any channel can wait before it hears about a waiting session (2, 5 or 15 minutes, an hour), which makes escalation channels a one-line setup. For the Slack app, the page gives you a manifest to paste into api.slack.com; set the interactivity URL to https://<your Tessera>/api/integrations/slack/actions.
Make an API key under API keys. A key acts as the person who made it, with their current role, limited to the scopes picked (fleet:read, sessions:act, analytics:read, audit:read, policy:read, policy:write), and can expire. The public API is the console's own API under /api/v1, described at /api/v1/openapi.json (OpenAPI 3.1).
The tessera CLI is served by your server, like the reporter:
npx -y --package=$TESSERA/downloads/tessera-cli.tgz tessera login --server $TESSERA
tessera sessions --waiting # what is waiting on someone
tessera watch # a line (and a bell) when a session starts waiting
tessera allow 3f9c # answer by id prefix or name; deny, approve, reject, answer, message
tessera pause 3f9c --reason "freeze" ; tessera stop 3f9c
tessera spend --days 30 --by cost_center
tessera export --by cost_center --from 2026-10-01 --to 2026-10-31 -o october.csv
tessera policy get -o policy.json # edit, review in Git, then:
tessera policy apply policy.json --note "Block prod MCP tools"
In CI, set TESSERA_URL and TESSERA_API_KEY instead of logging in. policy apply shows the diff and refuses to overwrite a version saved since you read it.
Under Data and compliance, owners decide what Tessera keeps:
hooks/register.js 285 lines1// The Tessera mod. It runs inside Claude Code beside the Tessera reporter's
2// settings hooks, which still report every event and handle prompts. The mod
3// adds what a hook cannot do:
4//
5// - A message sent from the Tessera console reaches this session even while
6// it sits idle at the prompt. Hooks only fire when Claude Code does
7// something, so before, a message waited for the end of the next turn.
8// - /tessera shows the connection and sets away mode from the terminal, and a
9// band above the prompt says when away mode is on.
10// - Claude Code's own context and cost figures go to the reporter through a
11// small file in ~/.tessera/live, in place of what the transcript implies.
12//
13// It reads the reporter's enrolment from ~/.tessera/config.json and never
14// writes the token anywhere or logs it.
15
16const VERSION = '0.1.0'
17/** How often the background loop looks at what is due. */
18const TICK_MS = 5_000
19/** The long poll for messages: the server holds the request this long when nothing is queued. */
20const POLL_WAIT_S = 20
21/** How often the live file is refreshed, so the reporter knows the mod is still running. */
22const LIVE_EVERY_MS = 30_000
23/** How often away mode is read back from the server, for the band. */
24const WHOAMI_EVERY_MS = 60_000
25/** What Claude Code's result or error says when a prompt was refused after the user pressed Esc. */
26const REFUSED = ['the session was stopped and nobody has sent input since', 'was removed from the queue before it ran']
27
28let cfg = null
29let home = null
30let sessionId = null
31let user = null
32let device = null
33let presence = 'auto'
34let lastLiveAt = 0
35let lastWhoamiAt = 0
36let polling = false
37let pollFailures = 0
38let pollPausedUntil = 0
39let ended = false
40/** Messages Claude Code refused to start a turn for, after an interruption; sent again when a turn completes. */
41let retry = []
42/** The latest figures from session.measure, written to the live file. */
43let measured = null
44
45/** The reporter's home: TESSERA_HOME, or .tessera in the user's home directory. */
46async function tesseraHome($) {
47 const set = await $.env.get('TESSERA_HOME')
48 if (set) return set
49 const h = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
50 return h ? h + '/.tessera' : null
51}
52
53/** The enrolment the reporter saved, or null when this machine is not enrolled. */
54async function loadConfig($) {
55 home = await tesseraHome($)
56 if (!home) return null
57 try {
58 const raw = JSON.parse(await $.fs.read(home + '/config.json'))
59 if (typeof raw.serverUrl !== 'string' || typeof raw.token !== 'string' || !raw.serverUrl || !raw.token) return null
60 return {
61 serverUrl: raw.serverUrl.replace(/\/+$/, ''),
62 token: raw.token,
63 permissionWaitSeconds: typeof raw.permissionWaitSeconds === 'number' ? raw.permissionWaitSeconds : 55,
64 }
65 } catch {
66 return null
67 }
68}
69
70/** One request to the Tessera server with the device token. Resolves to the parsed body, or null with the reason logged. */
71async function api($, method, path, body) {
72 if (!cfg) return null
73 try {
74 const r = await $.http.fetch(cfg.serverUrl + path, {
75 method,
76 headers: { authorization: 'Bearer ' + cfg.token, 'content-type': 'application/json', accept: 'application/json' },
77 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
78 })
79 if (!r.ok) {
80 $.ui.log('Tessera ' + method + ' ' + path.split('?')[0] + ' answered ' + r.status, { to: 'debug' })
81 return null
82 }
83 return r.text ? JSON.parse(r.text) : {}
84 } catch (err) {
85 // Network, proxy or a body that is not JSON. The message carries no token: the token only travels in a header.
86 $.ui.log('Tessera ' + method + ' ' + path.split('?')[0] + ' failed: ' + String(err && err.message ? err.message : err).slice(0, 200), { to: 'debug' })
87 return null
88 }
89}
90
91/** Write Claude Code's figures for the reporter. The reporter ignores a file older than 90 s. */
92async function writeLive($, figures, now) {
93 if (!home || !sessionId) return
94 const f = figures || (await $.session.usage())
95 const context = f && f.context && f.context.window ? { tokens: typeof f.context.tokens === 'number' ? f.context.tokens : null, window: f.context.window } : null
96 const file = { v: 1, at: now, sessionId, modVersion: VERSION, context, costUsd: f && f.cost && typeof f.cost.usd === 'number' ? f.cost.usd : null }
97 try {
98 await $.fs.write(home + '/live/' + sessionId.replace(/[^a-zA-Z0-9_-]/g, '_') + '.json', JSON.stringify(file))
99 lastLiveAt = now
100 } catch (err) {
101 $.ui.log('Tessera could not write its live file: ' + String(err && err.message ? err.message : err).slice(0, 200), { to: 'debug' })
102 }
103}
104
105/** Read back who this device is and whether away mode is on. */
106async function refreshWhoami($, now) {
107 lastWhoamiAt = now
108 const who = await api($, 'GET', '/api/ingest/whoami')
109 if (!who) return false
110 user = typeof who.user === 'string' ? who.user : user
111 device = typeof who.device === 'string' ? who.device : device
112 const was = presence
113 if (who.presence === 'auto' || who.presence === 'desk' || who.presence === 'away') presence = who.presence
114 if (was !== presence) $.ui.invalidate('ui.render')
115 return true
116}
117
118/** Submit one message from Tessera as a turn of its own. Claude Code holds it until the session is idle. */
119async function submitMessage($, d) {
120 const by = d.payload && typeof d.payload.by === 'string' ? d.payload.by : 'a reviewer'
121 const text = d.payload && typeof d.payload.text === 'string' ? d.payload.text : ''
122 if (!text) {
123 await api($, 'POST', '/api/ingest/deliveries/' + d.id + '/result', { result: { applied: false, reason: 'empty message' } })
124 return
125 }
126 let reason = ''
127 try {
128 const r = await $.prompt.submit({ text: 'Message from ' + by + ' via Tessera: ' + text })
129 reason = r && typeof r.drop === 'string' ? r.drop : ''
130 } catch (err) {
131 reason = String(err && err.message ? err.message : err)
132 }
133 if (REFUSED.some((x) => reason.includes(x))) {
134 // The user pressed Esc. Claude Code starts nothing until they type again, so send it after their next turn.
135 if (!retry.some((x) => x.id === d.id)) retry.push(d)
136 return
137 }
138 await api($, 'POST', '/api/ingest/deliveries/' + d.id + '/result', { result: reason ? { applied: false, reason: reason.slice(0, 300) } : { applied: true, via: 'mod' } })
139 if (!reason) $.ui.toast('Message from ' + by + ' delivered from Tessera')
140}
141
142/** One long poll for messages. Only messages: answers stay queued for the hook that is waiting on them. */
143async function pollMessages($, now) {
144 if (polling || now < pollPausedUntil) return
145 polling = true
146 try {
147 const r = await api($, 'GET', '/api/ingest/sessions/' + encodeURIComponent(sessionId) + '/deliveries?wait=' + POLL_WAIT_S + '&kinds=message')
148 if (!r) {
149 // Back off on failure, up to a minute, so an unreachable server is not hammered.
150 pollFailures += 1
151 pollPausedUntil = now + Math.min(60_000, TICK_MS * 2 ** Math.min(pollFailures, 4))
152 return
153 }
154 pollFailures = 0
155 for (const d of Array.isArray(r.deliveries) ? r.deliveries : []) if (d && d.kind === 'message') void submitMessage($, d)
156 } finally {
157 polling = false
158 }
159}
160
161/** The background loop: what is due, every few seconds, between and during turns. */
162async function tick($) {
163 if (!cfg || ended || !sessionId) return
164 const now = await $.clock.now()
165 if (now - lastLiveAt >= LIVE_EVERY_MS) await writeLive($, measured, now)
166 if (now - lastWhoamiAt >= WHOAMI_EVERY_MS) await refreshWhoami($, now)
167 void pollMessages($, now)
168}
169
170/** What a prompt waits for under each presence, as the reporter's hooks apply it. */
171function waitLine(p) {
172 if (p === 'away') return 'Away mode is on: prompts wait up to 10 minutes for an answer from Tessera, then the terminal asks.'
173 if (p === 'desk') return 'At your desk: prompts wait about 15 seconds for Tessera, then the terminal asks.'
174 return 'Away mode is off: prompts wait ' + (cfg ? cfg.permissionWaitSeconds : 55) + ' seconds for Tessera, then the terminal asks.'
175}
176
177/** Set away mode for this person, as the console's switch does. */
178async function setPresence($, p) {
179 const r = await api($, 'PUT', '/api/ingest/presence', { presence: p })
180 if (!r) return false
181 presence = r.presence === 'away' || r.presence === 'desk' ? r.presence : 'auto'
182 $.ui.invalidate('ui.render')
183 return true
184}
185
186async function statusText($) {
187 if (!cfg) return 'Tessera is not set up on this machine. Run the installer line from the Devices page of your Tessera console.'
188 const ok = await refreshWhoami($, await $.clock.now())
189 const lines = [
190 ok ? 'Reporting to ' + cfg.serverUrl + ' as ' + (user || 'you') + (device ? ' (device "' + device + '")' : '') + '.' : 'Cannot reach ' + cfg.serverUrl + ' just now. The session keeps working; Tessera catches up when the server answers.',
191 waitLine(presence),
192 'Messages sent from Tessera reach this session even while it is idle.',
193 ]
194 if (sessionId) lines.push('This session in Tessera: ' + cfg.serverUrl + '/app/sessions/' + encodeURIComponent(sessionId))
195 lines.push('Commands: /tessera away · /tessera back · /tessera desk')
196 return lines.join('\n')
197}
198
199async function start($, id) {
200 sessionId = id
201 ended = false
202 cfg = await loadConfig($)
203 if (!cfg) return
204 const now = await $.clock.now()
205 await writeLive($, null, now)
206 void refreshWhoami($, now)
207}
208
209export function register(on) {
210 on('session.start', async ($, e, next) => {
211 try {
212 await start($, await $.session.id())
213 $.clock.every(TICK_MS, async () => {
214 await tick($)
215 })
216 } catch (err) {
217 $.ui.log('Tessera mod did not start: ' + String(err && err.message ? err.message : err).slice(0, 200), { to: 'debug' })
218 }
219 // Last: a name already taken throws, and that must not stop the rest.
220 try {
221 await $.command.register({ name: 'tessera', description: 'Tessera: connection, away mode, and a link to this session', argumentHint: '[away|back|desk]', immediate: true })
222 } catch (err) {
223 $.ui.log('Tessera could not add /tessera: ' + String(err && err.message ? err.message : err).slice(0, 200), { to: 'debug' })
224 }
225 return next(e)
226 })
227
228 // /clear and /resume carry on under another session id.
229 on('classic.SessionStart', async ($, e, next) => {
230 if (e.session_id && e.session_id !== sessionId) await start($, e.session_id)
231 return next(e)
232 }).catch(async ($, e, next) => next(e))
233
234 on('session.measure', async ($, e, next) => {
235 measured = { context: e.context, cost: e.cost }
236 await writeLive($, measured, await $.clock.now())
237 return next(e)
238 })
239
240 on('turn.complete', async ($, e, next) => {
241 // A message refused after an interruption goes now that the user has typed again.
242 if (!e.agentId && retry.length) {
243 const again = retry
244 retry = []
245 for (const d of again) void submitMessage($, d)
246 }
247 return next(e)
248 })
249
250 // The reporter's SessionEnd hook deletes the live file, and one that stops being refreshed is ignored after 90 s.
251 on('session.end', async ($, e, next) => {
252 ended = true
253 return next(e)
254 })
255
256 on('command.run', { command: 'tessera' }, async ($, e) => {
257 const arg = String(e.args || '').trim().toLowerCase()
258 if (!cfg) return { text: await statusText($) }
259 if (arg === 'away' || arg === 'back' || arg === 'auto' || arg === 'desk') {
260 const want = arg === 'back' ? 'auto' : arg
261 const ok = await setPresence($, want)
262 return { text: ok ? waitLine(presence) : 'Could not reach ' + cfg.serverUrl + ' to change away mode. Try again, or use the switch in the console.' }
263 }
264 if (arg && arg !== 'status') return { text: 'Unknown option "' + arg.slice(0, 40) + '". Use /tessera, /tessera away, /tessera back or /tessera desk.' }
265 return { text: await statusText($) }
266 })
267
268 // The band above the prompt says when away mode is on, with a way back.
269 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
270 if (!cfg || presence !== 'away') return next(e)
271 const { Box, Text, Button } = $.ui.resolve(e)
272 const theirs = await next(e)
273 const band = Box({
274 flexDirection: 'row',
275 gap: 1,
276 children: [
277 Text({ color: 'yellow', children: ['Tessera · away mode'] }),
278 Text({ dimColor: true, wrap: 'truncate-end', children: ['Prompts wait up to 10 min for an answer from your phone or the console.'] }),
279 Button({ key: 'tessera-back', label: "I'm back", onPress: async () => { await setPresence($, 'auto') } }),
280 ],
281 })
282 return theirs ? Box({ flexDirection: 'column', children: [band, theirs] }) : band
283 })
284}
285