Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8d2ed60
feat(sidecar): the agent loop runs durably on the app's own tools
AojdevStudio Nov 7, 2026
c51a596
fix(sidecar): turns keep waiting with the fix named in every case, an…
AojdevStudio Nov 7, 2026
a8158e4
fix(sidecar): Astra's waits survive a crash, old turns never resume o…
AojdevStudio Nov 7, 2026
67a5b98
fix(sidecar): a refused sign-in makes Astra wait, and a link that cli…
AojdevStudio Nov 7, 2026
1014951
fix(sidecar): a command the agent runs ends with its sidecar
AojdevStudio Nov 7, 2026
049298f
fix(sidecar): Astra waits for a login saved after the refused one, no…
AojdevStudio Nov 7, 2026
1650de1
fix(sidecar): a command a crash cut off finishes once, even when the …
AojdevStudio Nov 7, 2026
76a3665
test(sidecar): the crash test has time for the watch and the rerun's …
AojdevStudio Nov 7, 2026
01076ba
fix(sidecar): a picture sent with a message is read only from inside …
AojdevStudio Nov 7, 2026
9a51af9
fix(sidecar): a picture converted on macOS is written outside the pro…
AojdevStudio Nov 7, 2026
457e819
fix(sidecar): a command's output on macOS no longer ends with the she…
AojdevStudio Nov 7, 2026
8a067a1
fix(sidecar): a turn refused after a newer login was saved goes on wi…
AojdevStudio Nov 7, 2026
fd931c5
fix(sidecar): a command that ends after its sidecar's crash keeps its…
AojdevStudio Nov 7, 2026
2e1b8ea
fix(sidecar): a slice a crash cut off is never run twice at once
AojdevStudio Nov 7, 2026
014908c
fix(sidecar): a login saved while a failure is read is not missed
AojdevStudio Nov 7, 2026
7f27eb1
fix(sidecar): a command's log is read from where the last read stoppe…
AojdevStudio Nov 7, 2026
025b2bf
fix(sidecar): stopped turns stay stopped, retries need a new login, a…
AojdevStudio Nov 7, 2026
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
344 changes: 344 additions & 0 deletions sidecar/src/agent.ts

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions sidecar/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { parseArgs } from 'node:util'
import type { MutableModels } from '@earendil-works/pi-ai'
import { createRegistry } from '@earendil-works/pi-durable'
import { agentExtension, agentTools, ProjectEnv } from './agent'
import { Chats } from './chat'
import { Host } from './host'
import { inferenceModels, KeychainCredentials, Logins } from './inference'
import { handlers } from './methods'
import { EXIT, type SidecarEvent } from './protocol'
import { Store, StoreInUse } from './store'

export interface SidecarOptions {
argv: string[]
/** Tests only: providers beside the app's three, such as a scripted model. */
addProviders?(models: MutableModels): void
}

/** The sidecar from start to quit: open the store, serve the pipe until stdin closes, then close the store. */
export async function runSidecar({ argv, addProviders }: SidecarOptions): Promise<never> {
// Stdout carries protocol lines only, so a library that logs cannot corrupt the pipe.
console.log = console.error
console.info = console.error
console.debug = console.error

const { values } = parseArgs({
args: argv,
options: { 'app-version': { type: 'string' }, data: { type: 'string' }, projects: { type: 'string' } },
strict: true,
})
const { data, projects } = values
if (data === undefined || projects === undefined) throw new Error('usage: --data <store folder> --projects <projects folder>')
const version = values['app-version'] ?? 'dev'

const host = new Host((line) => process.stdout.write(line))
// The credentials keep their index in the store, which needs the models to open, so the index is reached late.
const credentials = new KeychainCredentials(host, {
logins: () => store.logins(),
setLogin: (provider, kind) => store.setLogin(provider, kind),
})
const models = inferenceModels(credentials)
addProviders?.(models)

const registry = createRegistry()
const tools = agentTools({
host,
projectOf: (conversation) => store.projectOf(conversation),
signedIn: (conversation, provider, context, after) => chats.loginFor(conversation, provider, context, after),
loginSaves: (provider) => store.loginSaves(provider),
})
const extension = agentExtension(tools)
registry.install(extension)

let store: Store
try {
store = await Store.open({
dataDir: data,
projectsDir: projects,
models,
registry,
env: ({ cwd }) => {
if (cwd === undefined) throw new Error('this conversation has no project folder')
return new ProjectEnv({ cwd })
},
})
} catch (error) {
if (!(error instanceof StoreInUse)) throw error
console.error(error.message)
process.exit(EXIT.storeInUse)
}
// A store that fails is not used again. Rust starts a new sidecar, which reopens it from what was committed.
void store.harness.closed.then((end) => {
if (end.kind !== 'failed') return
console.error(`the store failed: ${end.error instanceof Error ? end.error.message : String(end.error)}`)
process.exit(1)
})

const emit = (event: SidecarEvent) => {
host.emit(event)
if (event.type === 'login' && event.step === 'done') {
chats.signedIn(event.provider).catch((error: unknown) => console.error(`resuming turns after a sign-in failed: ${String(error)}`))
}
}
const chats = new Chats({ store, models, extension, tools, version, emit })
const logins = new Logins(models, credentials, emit)
console.error(`agent tools: ${tools.map((tool) => tool.name).join(', ')}`)

// The pipe opens before the reconcile, which asks Rust's Keychain whether a waited-on login is there now.
const serving = host.serve(handlers({ store, logins, chats, version }), process.stdin)
host.emit({ type: 'ready', version })
await chats.reconcile()
await serving
await chats.close()
await store.close()
process.exit(EXIT.done)
}
Loading
Loading