Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ extension.pem
ModelReport.md
session-*.md
.claude/settings.json

# local backups (never commit)
api-proxy-cf.backup-*/
122 changes: 122 additions & 0 deletions INTERSESSION_DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Inter-Session Communication — Design Spec

Status: **IMPLEMENTED** (CLI). Built on the existing `BUS`/`send_message`
infrastructure rather than a parallel channel.

## What shipped
- `src/agent/sessionRegistry.js` — live session registry (keyed by agent label).
`registerSession()` broadcasts a creation notice to every other session's
mailbox; `trackToolFiles()` records recently-touched files per session.
- `src/agent/agent.js` — every `Agent` registers itself (with model) and updates
its goal/status each turn; file-touching tools feed `trackToolFiles`.
- `src/agent/tools.js` — `list_sessions` (peer discovery) and `query_session`
(returns a peer's goal/status + recently-touched files; optional `question`
delivered to the peer's inbox). Both added to the hosted-model allowlist and
the grant-independent set.
- `src/tui/App.jsx` — hidden `/create-external-model <https://url> <model> <key>`
dev command (absent from `COMMANDS`, so it never tab-completes).

## Known gap
- The user-facing `main` agent does not see creation notices via `read_messages`
(BUS routes `to:"main"` to an internal inbox that `read_messages` doesn't read;
`main` can still call `list_sessions` to discover peers). Spawned sub-agents
*are* notified correctly. Fixing `main` would require changing `read_messages`
and risks colliding with the spawn flow's own `readMain()` consumption.

## Original design notes (kept for reference)


## Goal (from request)

1. Code-chat **sessions** can talk to each other.
2. A model is **notified when another session is created**.
3. A session can **ask another session what it's doing / how to avoid each other**.
4. A **tool** the model can call to do the above.
5. A **developer command** `/create-external-model <https://url> <model name> <api key>`
that registers an external OpenAI-compatible model — and is **deliberately NOT
tab-completable**.

## Integration reality (from reading the code)

- Slash-command dispatch = `runCommand(raw)` in `src/tui/App.jsx` (big `switch`).
- `src/ui/commands.js` `COMMANDS` array is **only** for suggestions + tab
completion (`getSuggestions` / `getTabCompletion`). A command omitted from
`COMMANDS` still runs via the `runCommand` switch but never autocompletes.
→ `/create-external-model` is hidden simply by not listing it in `COMMANDS`.
- Custom endpoints live in `CUSTOM_ENDPOINTS` (mutable, `src/config.js`); the
`/endpoint` handler (App.jsx ~2168) shows the exact mutate + `saveCustomEndpoints(...)`
pattern to mirror.
- Tools are defined in `src/agent/tools.js`; agents get them filtered via
`agentRegistry.filterTools` (permission rulesets).
- Sessions/agent loop: `src/agent/agentRegistry.js` (named agents, not live
sessions) + the live chat loop in `src/tui/App.jsx` / `src/agent/agent.js`.

## Proposed design

### 1. `src/agent/sessionRegistry.js` (new, process-wide singleton)
In-memory registry of **currently-running** sessions (live coordination only —
no persistence needed).

Record shape:
```
Comment thread
Ravikxx marked this conversation as resolved.
{ id, name, model, goal, status, owner, createdAt, lastActivity, running, turnCount }
```
API:
- `register(session)` / `unregister(id)`
- `list()` → public descriptors (omit sensitive fields)
- `get(id)`
- `updateStatus(id, { goal, status })` — called each turn so peers can answer
"what are you doing"
- `notifyCreation(session)` — enqueue a creation notice into every *other*
session's `inbound` queue
- per-session `inbound` queue drained at the start of each agent turn

### 2. Creation notifications
Hook `register()` + `notifyCreation()` wherever a new chat/code-session spawns
(new chat in App.jsx; any spawned agent loop in agent.js). Each other live
session drains its `inbound` queue at the top of its next turn and surfaces the
notice as a `system` message:
`"New session '<name>' started on model <model> — goal: <goal>."`
(Draining at turn-start avoids interrupting a mid-turn agent.)

### 3. Model tools (`src/agent/tools.js`)
- `list_sessions` → returns peer sessions (id, name, model, status, goal),
**excluding the calling session itself**.
- `query_session({ sessionId, question })` → "what are you working on / how
should we avoid conflicts?". **v1 = synchronous status lookup**: returns the
target's last `goal` + `status` + recent file activity (from the registry
record), not a full back-and-forth. True async peer-to-peer chat = v2.
- Both gated through `agentRegistry.filterTools` so denied/allowed tool rules
still apply. `query_session` must never return another session's full message
history — only goal/status + summarized activity (privacy boundary).

### 4. `/create-external-model` (hidden dev command)
Signature: `/create-external-model <https://url> <model name> <api key>`
Handler (new `case` in `runCommand`, App.jsx), mirroring `/endpoint`:
```
CUSTOM_ENDPOINTS[name] = { baseURL: url, model: name, apiKey: key, context: 0 }
CONTEXT_WINDOWS[name] = <fetched or default>
saveCustomEndpoints({ ...CUSTOM_ENDPOINTS })
setModel(name); agentRef.current?.setModel(name); saveModel(name)
Comment thread
Ravikxx marked this conversation as resolved.
```
Validation: `url` must start with `http(s)://`. Gated as developer-only
(undocumented; always available but absent from `COMMANDS`, so no tab-complete).
Comment thread
Ravikxx marked this conversation as resolved.
Comment thread
Ravikxx marked this conversation as resolved.

## Open questions to resolve before implementing
- **Scope**: CLI sessions only, or also the desktop "code chats" (Axion App
Code tab)? Tools/slash-commands here are CLI-only.
- **Session definition**: a whole chat, or a spawned sub-agent? Affects where
`register()` is hooked.
- **v1 vs v2** for `query_session`: status-polling (simple, synchronous) vs.
real async agent-to-agent messaging (needs a request/response channel +
timeout). Recommend v1 status-polling first.
- Notification timing: turn-start drain (chosen) vs. push interrupt.

## Files touched (when implemented)
- new: `src/agent/sessionRegistry.js`
- `src/agent/tools.js` (2 tools)
- `src/tui/App.jsx` (runCommand: creation hook + `/create-external-model` case;
model tool available to tools list)
- `src/agent/agent.js` (drain `inbound` + `updateStatus` each turn)
- `src/ui/commands.js` — **NOT** modified for the hidden command (intentionally
absent so it stays out of tab-completion).
20 changes: 20 additions & 0 deletions api-proxy-cf/migrations/042_client_errors.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Client-side error reports from the iPhone app (and any future native client).
-- The app shows the user only a generic "Something went wrong" and ships the
-- real failure here so we can triage crashes and exceptions without ever
-- exposing internal details (stacks, underlying errors) to users.
CREATE TABLE client_errors (
id TEXT PRIMARY KEY,
user_id TEXT,
app_version TEXT,
build_number TEXT,
os_version TEXT,
device_model TEXT,
type TEXT,
message TEXT,
stack TEXT,
context TEXT,
created_at INTEGER NOT NULL
);

CREATE INDEX idx_client_errors_created ON client_errors (created_at DESC);
CREATE INDEX idx_client_errors_user ON client_errors (user_id, created_at DESC);
2 changes: 1 addition & 1 deletion api-proxy-cf/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "node --test test/*.test.mjs",
"check": "node --check src/index.js && node --check src/avatar.js && node --check src/billing.js && node --check src/chatGeneration.js && node --check src/lumen-upstream.js && node --check src/veil-upstream.js && node --check src/status.js && node --check src/sandbox.js && node --check src/auditLog.js && node --check src/messageReview.js && node --check src/moderationAdmin.js && node --check src/webOrigins.js"
"check": "node --check src/index.js && node --check src/avatar.js && node --check src/billing.js && node --check src/chatGeneration.js && node --check src/fresco-upstream.js && node --check src/glyph-upstream.js && node --check src/status.js && node --check src/sandbox.js && node --check src/auditLog.js && node --check src/messageReview.js && node --check src/moderationAdmin.js && node --check src/webOrigins.js"
},
"dependencies": {
"hono": "^4.4.0"
Expand Down
17 changes: 17 additions & 0 deletions api-proxy-cf/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,20 @@ CREATE TABLE IF NOT EXISTS api_keys (
tokens INTEGER DEFAULT 0,
revoked INTEGER DEFAULT 0
);

CREATE TABLE IF NOT EXISTS client_errors (
id TEXT PRIMARY KEY,
user_id TEXT,
app_version TEXT,
build_number TEXT,
os_version TEXT,
device_model TEXT,
type TEXT,
message TEXT,
stack TEXT,
context TEXT,
created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_client_errors_created ON client_errors (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_client_errors_user ON client_errors (user_id, created_at DESC);
107 changes: 107 additions & 0 deletions api-proxy-cf/src/chatGeneration.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ALLOWED_WEB_ORIGINS } from './webOrigins.js'

const COMPLETIONS_URL = 'https://api.sennoric.com/v1/chat/completions'
const ARTIFACT_CONTENT_LIMIT = 500_000
const ARTIFACT_KINDS = new Set(['text', 'code', 'markdown'])

// Partial text is written to storage at most this often. Frequent enough that a
// reader attaching after an eviction sees almost everything, rare enough that a
Expand Down Expand Up @@ -95,11 +97,13 @@ export class ChatGeneration {
this.toolCalls = []
this.terminal = null // { status, error } once the generation has settled
this.persistedAt = 0
this.cancelRequested = false
}

async fetch(request) {
const url = new URL(request.url)
if (request.method === 'POST' && url.pathname === '/start') return this.start(request)
if (request.method === 'POST' && url.pathname === '/cancel') return this.cancel()
if (request.method === 'GET' && url.pathname === '/stream') return this.openStream(request)
return json({ error: 'Not found' }, 404)
}
Expand All @@ -121,6 +125,22 @@ export class ChatGeneration {
return json({ ok: true, id: incoming.id }, 202)
}

async cancel() {
const job = await this.state.storage.get('job')
const terminal = this.terminal || await this.state.storage.get('terminal')
if (!job) {
return json({ ok: true, status: terminal?.status || 'cancelled' })
}

this.cancelRequested = true
await this.state.storage.put('cancelRequested', true)
await this.env.DB.prepare(
"UPDATE chat_generations SET status='cancelled', error=NULL, completed=? WHERE id=? AND user_id=? AND status IN ('queued','running')"
).bind(Date.now(), job.id, job.userId).run().catch(() => {})
await this.settle({ status: 'cancelled' })
return json({ ok: true, status: 'cancelled' })
}

// Replays everything generated so far, then streams the rest live. A tab that
// joins at any point gets the same complete reply as one that watched from
// the start, so reconnecting never shows a half message.
Expand Down Expand Up @@ -199,6 +219,7 @@ export class ChatGeneration {
}

async append(chunk) {
if (this.cancelRequested) return
this.text += chunk
this.broadcast('delta', { text: chunk })
const now = Date.now()
Expand All @@ -211,6 +232,11 @@ export class ChatGeneration {
async alarm() {
const job = await this.state.storage.get('job')
if (!job) return
this.cancelRequested = Boolean(await this.state.storage.get('cancelRequested'))
if (this.cancelRequested) {
await this.finishCancelledDrain()
return
}

// The model already answered but the D1 commit failed. Retry only the
// commit — re-running the model would charge the user a second time.
Expand Down Expand Up @@ -251,6 +277,33 @@ export class ChatGeneration {
return
}

if (this.cancelRequested) {
await this.finishCancelledDrain()
return
}

const artifactCalls = this.toolCalls.filter(call => call?.function?.name === 'create_cloud_artifact')
if (artifactCalls.length) {
const confirmations = []
for (const [index, artifactCall] of artifactCalls.entries()) {
if (this.cancelRequested) {
await this.finishCancelledDrain()
return
}
try {
confirmations.push(await this.createArtifact(job, artifactCall, index))
} catch (error) {
await this.fail(job, `Could not create the artifact: ${errorText(error)}`)
return
}
}
if (this.text && !this.text.endsWith('\n')) await this.append('\n\n')
await this.append(confirmations.join('\n'))
// The hosted worker executed these calls. Do not expose them as pending
// client-side tool calls, or another client could execute them again.
this.toolCalls = this.toolCalls.filter(call => call?.function?.name !== 'create_cloud_artifact')
}

Comment thread
Ravikxx marked this conversation as resolved.
if (!this.text && !this.toolCalls.length) {
await this.fail(job, 'Fresco returned an empty reply')
return
Expand All @@ -267,6 +320,41 @@ export class ChatGeneration {
await this.commitResult(job)
}

async createArtifact(job, call, index = 0) {
let input
try {
input = JSON.parse(call?.function?.arguments || '{}')
} catch {
return 'I could not create the artifact because the generated artifact details were invalid. Please try again.'
}
if (typeof input?.content !== 'string') {
return 'I could not create the artifact because it had no content. Please try again.'
}
if (input.content.length > ARTIFACT_CONTENT_LIMIT) {
return 'I could not create the artifact because its content was too large. Please ask for a smaller artifact.'
}

const title = String(input.title || 'Untitled').trim().slice(0, 200) || 'Untitled'
const kind = ARTIFACT_KINDS.has(input.kind) ? input.kind : 'text'
const language = kind === 'code' && input.language ? String(input.language).slice(0, 50) : null
// Deterministic IDs make an alarm retry idempotent if the artifact write
// succeeds but Durable Object storage is interrupted before result commit.
const id = `artifact-${job.id}${index ? `-${index + 1}` : ''}`
const revisionId = `${id}-revision-1`
const now = Date.now()
await this.env.DB.batch([
this.env.DB.prepare(
'INSERT OR IGNORE INTO artifact_revisions (id, artifact_id, content, created) VALUES (?,?,?,?)'
).bind(revisionId, id, input.content, now),
this.env.DB.prepare(
`INSERT OR IGNORE INTO artifacts
(id, user_id, project_id, chat_id, title, kind, language, latest_revision_id, created, updated)
VALUES (?,?,?,?,?,?,?,?,?,?)`
).bind(id, job.userId, null, job.chatId, title, kind, language, revisionId, now, now),
])
return `Created artifact “${title}” in your Sennoric account.`
}

Comment thread
Ravikxx marked this conversation as resolved.
// Parses the upstream SSE stream, appending content deltas and accumulating
// tool calls, which arrive in fragments indexed by position.
async consume(body) {
Expand All @@ -290,6 +378,7 @@ export class ChatGeneration {
try { delta = JSON.parse(payload).choices?.[0]?.delta } catch { continue }
if (!delta) continue

if (this.cancelRequested) continue
if (typeof delta.content === 'string' && delta.content) await this.append(delta.content)

for (const call of delta.tool_calls || []) {
Expand All @@ -307,6 +396,10 @@ export class ChatGeneration {
}

async commitResult(job) {
if (this.cancelRequested || await this.state.storage.get('cancelRequested')) {
await this.finishCancelledDrain()
return
}
let row
try {
row = await this.env.DB.prepare(
Expand Down Expand Up @@ -367,6 +460,10 @@ export class ChatGeneration {
}

async fail(job, message) {
if (this.cancelRequested || await this.state.storage.get('cancelRequested')) {
await this.finishCancelledDrain()
return
}
await this.env.DB.prepare(
"UPDATE chat_generations SET status='failed', error=?, completed=? WHERE id=? AND user_id=?"
).bind(errorText(message), Date.now(), job.id, job.userId).run().catch(() => {})
Expand All @@ -385,4 +482,14 @@ export class ChatGeneration {
this.broadcast(terminal.status === 'failed' ? 'error' : 'done', terminal)
this.closeSubscribers()
}

async finishCancelledDrain() {
this.cancelRequested = true
if (!this.terminal) await this.settle({ status: 'cancelled' })
await Promise.all([
this.state.storage.delete('job'),
this.state.storage.delete('partial'),
this.state.storage.delete('cancelRequested'),
])
}
}
Loading
Loading