zooid 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +8 -3
  2. package/dist/bin.js +503 -124
  3. package/dist/bin.js.map +1 -1
  4. package/dist/{chunk-KGYQ5YNP.js → chunk-YZ4IO5MR.js} +78 -11
  5. package/dist/chunk-YZ4IO5MR.js.map +1 -0
  6. package/dist/index.js +1 -1
  7. package/package.json +11 -9
  8. package/src/bin.test.ts +63 -0
  9. package/src/bin.ts +55 -6
  10. package/src/bootstrap/configs.test.ts +7 -0
  11. package/src/bootstrap/configs.ts +14 -0
  12. package/src/build-registry.ts +1 -1
  13. package/src/build-registry.zod044.test.ts +23 -0
  14. package/src/commands/dev.ts +37 -7
  15. package/src/commands/init/generators.test.ts +43 -0
  16. package/src/commands/init/generators.ts +24 -5
  17. package/src/commands/init/pi-scaffold.test.ts +151 -0
  18. package/src/commands/init/prompts.ts +53 -2
  19. package/src/commands/init/registry.test.ts +60 -0
  20. package/src/commands/init/registry.ts +58 -0
  21. package/src/commands/init/sniff.test.ts +61 -2
  22. package/src/commands/init/sniff.ts +43 -12
  23. package/src/commands/init.ts +91 -5
  24. package/src/commands/status.test.ts +15 -0
  25. package/src/commands/status.ts +27 -2
  26. package/src/daemon/start-daemon.ts +15 -1
  27. package/src/push-gateway/gateway.test.ts +150 -0
  28. package/src/push-gateway/gateway.ts +78 -0
  29. package/src/push-gateway/index.ts +17 -0
  30. package/src/push-gateway/payload.test.ts +88 -0
  31. package/src/push-gateway/payload.ts +39 -0
  32. package/src/push-gateway/types.ts +37 -0
  33. package/src/push-gateway/vapid.test.ts +45 -0
  34. package/src/push-gateway/vapid.ts +34 -0
  35. package/src/services/tuwunel.ts +21 -2
  36. package/src/version.test.ts +67 -0
  37. package/src/version.ts +30 -0
  38. package/src/web/static.test.ts +22 -0
  39. package/src/web/static.ts +9 -1
  40. package/dist/chunk-KGYQ5YNP.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import { select, password } from '@inquirer/prompts'
2
- import { findOpencodeProvider, findSimplePreset } from './registry.js'
2
+ import { findOpencodeProvider, findPiProvider, findSimplePreset, PI_DEFAULT_PROVIDER } from './registry.js'
3
+ import { sniffCredentials } from './sniff.js'
3
4
  import type { InitOptions } from '../init.js'
4
5
 
5
6
  export interface PromptInput {
@@ -28,9 +29,10 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
28
29
  { name: 'claude (Claude Code)', value: 'claude' as const },
29
30
  { name: 'codex (OpenAI Codex)', value: 'codex' as const },
30
31
  { name: 'opencode', value: 'opencode' as const },
32
+ { name: 'pi', value: 'pi' as const },
31
33
  ],
32
34
  }),
33
- () => '--preset is required (claude | codex | opencode)',
35
+ () => '--preset is required (claude | codex | opencode | pi)',
34
36
  ))
35
37
 
36
38
  if (preset === 'opencode') {
@@ -64,6 +66,55 @@ export async function resolveOptions(flags: PromptInput): Promise<InitOptions> {
64
66
  }
65
67
  }
66
68
 
69
+ if (preset === 'pi') {
70
+ // Offer, never infer: detecting a login only ever adds a question, it
71
+ // never silently decides the answer.
72
+ const loginFound = sniffCredentials('pi').found
73
+ const auth =
74
+ (flags.auth as 'subscription' | 'api-key') ??
75
+ (loginFound
76
+ ? await ask(
77
+ () =>
78
+ select({
79
+ message: 'A pi login was found. How should the agent authenticate?',
80
+ choices: [
81
+ { name: 'Share my pi login (agent uses my existing subscription)', value: 'subscription' as const },
82
+ { name: "Give the agent its own key (billed and revoked separately)", value: 'api-key' as const },
83
+ ],
84
+ }),
85
+ () => '--auth is required (subscription | api-key)',
86
+ )
87
+ : ('api-key' as const))
88
+
89
+ if (auth === 'subscription') {
90
+ return {
91
+ dir: flags.dir,
92
+ preset,
93
+ auth,
94
+ force: flags.force,
95
+ overwrite: flags.overwrite,
96
+ }
97
+ }
98
+
99
+ const provider = flags.provider ?? PI_DEFAULT_PROVIDER
100
+ const providerMeta = findPiProvider(provider)
101
+ if (!providerMeta) throw new Error(`unknown pi provider: ${provider}`)
102
+ const apiKey = flags.apiKey ?? (await ask(
103
+ () => password({ message: `${providerMeta.label} API key:` }),
104
+ () => '--api-key is required',
105
+ ))
106
+ return {
107
+ dir: flags.dir,
108
+ preset,
109
+ auth,
110
+ provider,
111
+ model: flags.model,
112
+ apiKey,
113
+ force: flags.force,
114
+ overwrite: flags.overwrite,
115
+ }
116
+ }
117
+
67
118
  const meta = findSimplePreset(preset)
68
119
  if (!meta) throw new Error(`unknown preset: ${preset}`)
69
120
  const auth = (flags.auth as 'subscription' | 'api-key') ?? (await ask(
@@ -4,6 +4,14 @@ import {
4
4
  OPENCODE_PROVIDERS,
5
5
  findSimplePreset,
6
6
  findOpencodeProvider,
7
+ PI_PROVIDERS,
8
+ PI_DEFAULT_PROVIDER,
9
+ PI_DEFAULT_MODEL,
10
+ PI_AGENT_DIR,
11
+ PI_AUTH_FILE,
12
+ PI_SETTINGS_FILE,
13
+ PI_AUTH_MODES,
14
+ findPiProvider,
7
15
  } from './registry.js'
8
16
 
9
17
  describe('init registry', () => {
@@ -31,3 +39,55 @@ describe('init registry', () => {
31
39
  expect(findOpencodeProvider('nope')).toBeUndefined()
32
40
  })
33
41
  })
42
+
43
+ describe('pi registry (ZOD075)', () => {
44
+ it('PI_PROVIDERS leads with openrouter, the verified default', () => {
45
+ expect(PI_PROVIDERS[0]?.id).toBe('openrouter')
46
+ })
47
+
48
+ it('every provider carries an API-key env var', () => {
49
+ for (const p of PI_PROVIDERS) {
50
+ expect(p.apiKeyEnvVar).toMatch(/^[A-Z_]+_API_KEY$/)
51
+ expect(p.label.length).toBeGreaterThan(0)
52
+ }
53
+ })
54
+
55
+ // anthropic stays reachable, but must not be the default: pi bills Claude
56
+ // Pro/Max through "extra usage", which starts at zero, so a claude default
57
+ // 400s for exactly the operator most likely to try Zooid.
58
+ it('includes anthropic as a choice but not as the default', () => {
59
+ expect(PI_PROVIDERS.map((p) => p.id)).toContain('anthropic')
60
+ expect(PI_DEFAULT_PROVIDER).not.toBe('anthropic')
61
+ })
62
+
63
+ it('pins the verified fallback provider and model', () => {
64
+ expect(PI_DEFAULT_PROVIDER).toBe('openrouter')
65
+ expect(PI_DEFAULT_MODEL).toBe('deepseek/deepseek-v4-pro')
66
+ })
67
+
68
+ it('findPiProvider returns the entry by id', () => {
69
+ expect(findPiProvider('openrouter')?.id).toBe('openrouter')
70
+ expect(findPiProvider('nope')).toBeUndefined()
71
+ })
72
+
73
+ // pi DOES share the subscription-or-key binary (that is what PI_AUTH_MODES
74
+ // encodes). It stays out of SIMPLE_PRESETS for the other reason: those rows
75
+ // carry ONE apiKeyEnvVar and ONE credentialDir, and pi needs a provider table.
76
+ it('pi is NOT in SIMPLE_PRESETS — those rows cannot hold a provider table', () => {
77
+ expect(SIMPLE_PRESETS.map((p) => p.preset)).not.toContain('pi')
78
+ })
79
+
80
+ it('exposes the agent dir and the home-relative pi paths', () => {
81
+ expect(PI_AGENT_DIR).toBe('.pi-agent')
82
+ expect(PI_AUTH_FILE).toBe('.pi/agent/auth.json')
83
+ expect(PI_SETTINGS_FILE).toBe('.pi/agent/settings.json')
84
+ })
85
+ })
86
+
87
+ describe('pi auth binary (ZOD075)', () => {
88
+ // pi's auth surface is the UNION of the two existing shapes: opencode's
89
+ // provider table plus claude/codex's subscription-or-api-key binary.
90
+ it('accepts the same two auth values as claude/codex', () => {
91
+ expect(PI_AUTH_MODES).toEqual(['subscription', 'api-key'])
92
+ })
93
+ })
@@ -74,3 +74,61 @@ export function findSimplePreset(name: string): SimplePresetMeta | undefined {
74
74
  export function findOpencodeProvider(id: string): OpencodeProviderMeta | undefined {
75
75
  return OPENCODE_PROVIDERS.find((p) => p.id === id)
76
76
  }
77
+
78
+ export interface PiProviderMeta {
79
+ id: string
80
+ label: string
81
+ description: string
82
+ apiKeyEnvVar: string
83
+ }
84
+
85
+ // ZOD075. pi is multi-provider like opencode, but unlike opencode it also has an
86
+ // OAuth subscription tier (Claude Pro/Max, ChatGPT Plus/Pro, xAI, OpenRouter)
87
+ // whose tokens live in ~/.pi/agent/auth.json — which is why pi gets its own
88
+ // branch instead of a SIMPLE_PRESETS row.
89
+ export const PI_PROVIDERS: readonly PiProviderMeta[] = [
90
+ {
91
+ id: 'openrouter',
92
+ label: 'OpenRouter',
93
+ description: 'many providers via OpenRouter credits',
94
+ apiKeyEnvVar: 'OPENROUTER_API_KEY',
95
+ },
96
+ {
97
+ id: 'anthropic',
98
+ label: 'Anthropic',
99
+ description: 'Claude via direct Anthropic API key',
100
+ apiKeyEnvVar: 'ANTHROPIC_API_KEY',
101
+ },
102
+ {
103
+ id: 'openai',
104
+ label: 'OpenAI',
105
+ description: 'GPT via direct OpenAI API key',
106
+ apiKeyEnvVar: 'OPENAI_API_KEY',
107
+ },
108
+ ]
109
+
110
+ // The ONE pinned model in this wizard, and a deliberate exception to the rule at
111
+ // the top of this file. Every other harness picks its own current default; pi's
112
+ // resolved to a model that returned an empty turn (ZOD073), so pi must be told.
113
+ //
114
+ // Not anthropic: pi bills Claude Pro/Max through "extra usage" (per-token, NOT
115
+ // against the plan), which starts at zero — so a claude default returns
116
+ // `400 "You're out of extra usage"` for the operator most likely to try Zooid,
117
+ // and ACP surfaces that as silence. openrouter/deepseek-v4-pro is the verified
118
+ // fallback; an operator's own global pair is preferred over it (sniffPiDefaults).
119
+ export const PI_DEFAULT_PROVIDER = 'openrouter'
120
+ export const PI_DEFAULT_MODEL = 'deepseek/deepseek-v4-pro'
121
+
122
+ /** Agent dir, relative so one value is right under both local and container runtimes. */
123
+ export const PI_AGENT_DIR = '.pi-agent'
124
+ /** Home-relative paths to the operator's real pi install. */
125
+ export const PI_AUTH_FILE = '.pi/agent/auth.json'
126
+ export const PI_SETTINGS_FILE = '.pi/agent/settings.json'
127
+
128
+ // pi's auth surface is the union of the two existing shapes: opencode's
129
+ // provider table plus claude/codex's subscription-or-api-key binary.
130
+ export const PI_AUTH_MODES = ['subscription', 'api-key'] as const
131
+
132
+ export function findPiProvider(id: string): PiProviderMeta | undefined {
133
+ return PI_PROVIDERS.find((p) => p.id === id)
134
+ }
@@ -1,8 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { tmpdir } from 'node:os'
5
- import { sniffCredentials } from './sniff.js'
5
+ import { sniffCredentials, sniffPiDefaults } from './sniff.js'
6
6
 
7
7
  let fakeHome: string
8
8
 
@@ -39,3 +39,62 @@ describe('sniffCredentials', () => {
39
39
  expect(sniffCredentials('claude', fakeHome).found).toBe(true)
40
40
  })
41
41
  })
42
+
43
+ describe('sniffCredentials — pi (ZOD075)', () => {
44
+ // pi's credential is a FILE, not a directory like ~/.claude — existsSync
45
+ // covers both, but the path must point at auth.json.
46
+ it('reports found when ~/.pi/agent/auth.json exists', () => {
47
+ mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
48
+ writeFileSync(join(fakeHome, '.pi', 'agent', 'auth.json'), '{}')
49
+ const r = sniffCredentials('pi', fakeHome)
50
+ expect(r.found).toBe(true)
51
+ expect(r.path).toMatch(/auth\.json$/)
52
+ })
53
+
54
+ it('reports not-found when ~/.pi is absent', () => {
55
+ expect(sniffCredentials('pi', fakeHome).found).toBe(false)
56
+ })
57
+
58
+ it('reports not-found when ~/.pi/agent exists but holds no auth.json', () => {
59
+ mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
60
+ expect(sniffCredentials('pi', fakeHome).found).toBe(false)
61
+ })
62
+ })
63
+
64
+ describe('sniffPiDefaults (ZOD075)', () => {
65
+ const write = (obj: unknown) => {
66
+ mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
67
+ writeFileSync(join(fakeHome, '.pi', 'agent', 'settings.json'), JSON.stringify(obj))
68
+ }
69
+
70
+ // Inheriting a pair the operator already runs interactively beats any default
71
+ // this repo could guess — it is verified by use.
72
+ it('inherits defaultProvider and defaultModel from the global settings', () => {
73
+ write({ theme: 'dark', defaultProvider: 'openrouter', defaultModel: 'deepseek/deepseek-v4-flash' })
74
+ expect(sniffPiDefaults(fakeHome)).toEqual({
75
+ provider: 'openrouter',
76
+ model: 'deepseek/deepseek-v4-flash',
77
+ source: join(fakeHome, '.pi', 'agent', 'settings.json'),
78
+ })
79
+ })
80
+
81
+ it('returns undefined when no settings file exists', () => {
82
+ expect(sniffPiDefaults(fakeHome)).toBeUndefined()
83
+ })
84
+
85
+ // A settings file that pins neither is the common case for a casual pi user;
86
+ // half a pair is not inheritable either.
87
+ it('returns undefined when the pair is absent or incomplete', () => {
88
+ write({ theme: 'dark' })
89
+ expect(sniffPiDefaults(fakeHome)).toBeUndefined()
90
+ write({ defaultProvider: 'openrouter' })
91
+ expect(sniffPiDefaults(fakeHome)).toBeUndefined()
92
+ })
93
+
94
+ // Never let a corrupt personal config abort someone's onboarding.
95
+ it('returns undefined rather than throwing on malformed JSON', () => {
96
+ mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true })
97
+ writeFileSync(join(fakeHome, '.pi', 'agent', 'settings.json'), '{not json')
98
+ expect(sniffPiDefaults(fakeHome)).toBeUndefined()
99
+ })
100
+ })
@@ -1,30 +1,61 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
- import { findSimplePreset } from './registry.js'
4
+ import { findSimplePreset, PI_AUTH_FILE, PI_SETTINGS_FILE } from './registry.js'
5
5
 
6
6
  export interface SniffResult {
7
7
  found: boolean
8
- /** Absolute path that satisfied the sniff (the config directory). */
8
+ /** Absolute path that satisfied the sniff (the config directory, or file for pi). */
9
9
  path?: string
10
10
  }
11
11
 
12
12
  /**
13
- * Checks whether the operator's per-CLI config directory exists. NEVER reads
14
- * any file inside — only `existsSync` on the dir. One rule covers Linux,
15
- * macOS Keychain, and OS keyring without per-backend logic. The edge case
16
- * where the dir exists but the operator isn't actually logged in is accepted;
17
- * the spawned shim will surface a clearer error on first turn.
13
+ * Checks whether the operator's per-CLI config directory (or, for pi, the
14
+ * credential file) exists. NEVER reads any file inside — only `existsSync`.
15
+ * One rule covers Linux, macOS Keychain, and OS keyring without per-backend
16
+ * logic. The edge case where the dir exists but the operator isn't actually
17
+ * logged in is accepted; the spawned shim will surface a clearer error on
18
+ * first turn.
18
19
  *
19
20
  * `home` is overridable for tests.
20
21
  */
21
22
  export function sniffCredentials(
22
- preset: 'claude' | 'codex',
23
+ preset: 'claude' | 'codex' | 'pi',
23
24
  home: string = homedir(),
24
25
  ): SniffResult {
25
- const meta = findSimplePreset(preset)
26
- if (!meta) return { found: false }
27
- const full = join(home, meta.credentialDir)
26
+ // pi's credential is a file (auth.json), not a directory; existsSync covers
27
+ // both, so the only difference is which relative path we join.
28
+ const rel = preset === 'pi' ? PI_AUTH_FILE : findSimplePreset(preset)?.credentialDir
29
+ if (!rel) return { found: false }
30
+ const full = join(home, rel)
28
31
  if (existsSync(full)) return { found: true, path: full }
29
32
  return { found: false }
30
33
  }
34
+
35
+ export interface PiDefaults {
36
+ provider: string
37
+ model: string
38
+ /** Absolute path the pair came from, so the wizard can say where. */
39
+ source: string
40
+ }
41
+
42
+ /**
43
+ * Reads the operator's global pi defaults. A pair they already run
44
+ * interactively is verified by use, which beats any pin this repo could guess.
45
+ *
46
+ * Returns undefined unless BOTH keys are present — half a pair cannot boot pi.
47
+ * Never throws: a corrupt personal config must not abort onboarding.
48
+ */
49
+ export function sniffPiDefaults(home: string = homedir()): PiDefaults | undefined {
50
+ const source = join(home, PI_SETTINGS_FILE)
51
+ try {
52
+ const raw = JSON.parse(readFileSync(source, 'utf8')) as Record<string, unknown>
53
+ const provider = raw.defaultProvider
54
+ const model = raw.defaultModel
55
+ if (typeof provider !== 'string' || !provider) return undefined
56
+ if (typeof model !== 'string' || !model) return undefined
57
+ return { provider, model, source }
58
+ } catch {
59
+ return undefined
60
+ }
61
+ }
@@ -1,4 +1,5 @@
1
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
2
3
  import { dirname, join, resolve } from 'node:path'
3
4
  import {
4
5
  generateAgentsMd,
@@ -8,22 +9,28 @@ import {
8
9
  generateGitignore,
9
10
  generateOpencodeJson,
10
11
  generateOpencodeReadme,
12
+ generatePiSettings,
11
13
  generateZooidYaml,
12
14
  } from './init/generators.js'
13
15
  import {
14
16
  findOpencodeProvider,
17
+ findPiProvider,
15
18
  findSimplePreset,
19
+ PI_AGENT_DIR,
20
+ PI_AUTH_FILE,
21
+ PI_DEFAULT_MODEL,
22
+ PI_DEFAULT_PROVIDER,
16
23
  } from './init/registry.js'
17
- import { sniffCredentials } from './init/sniff.js'
24
+ import { sniffCredentials, sniffPiDefaults } from './init/sniff.js'
18
25
 
19
26
  export interface InitOptions {
20
27
  dir: string
21
- preset: 'claude' | 'codex' | 'opencode'
22
- /** Required for claude/codex; ignored for opencode. */
28
+ preset: 'claude' | 'codex' | 'opencode' | 'pi'
29
+ /** Required for claude/codex/pi; ignored for opencode. */
23
30
  auth?: 'subscription' | 'api-key'
24
31
  /** Optional model pin. Omitted by default — the harness uses its own default. */
25
32
  model?: string
26
- /** opencode provider id; defaults to `opencode-go` in the wizard. */
33
+ /** opencode/pi provider id; defaults to `opencode-go` for opencode. */
27
34
  provider?: string
28
35
  /** Required on api-key path; required for opencode. */
29
36
  apiKey?: string
@@ -31,6 +38,8 @@ export interface InitOptions {
31
38
  force?: boolean
32
39
  /** Required with `force` to overwrite existing files. */
33
40
  overwrite?: boolean
41
+ /** Overridable for tests; defaults to os.homedir(). */
42
+ home?: string
34
43
  }
35
44
 
36
45
  interface WriteSpec {
@@ -66,6 +75,7 @@ export async function runInit(opts: InitOptions): Promise<void> {
66
75
  }
67
76
 
68
77
  const writes: WriteSpec[] = []
78
+ let piInherited: { provider: string; model: string; source: string } | undefined
69
79
 
70
80
  if (opts.preset === 'claude' || opts.preset === 'codex') {
71
81
  if (!opts.auth) throw new Error('--auth (subscription|api-key) is required for claude/codex')
@@ -123,6 +133,47 @@ export async function runInit(opts: InitOptions): Promise<void> {
123
133
  content: generateEnv({ envVar: providerMeta!.apiKeyEnvVar, value: opts.apiKey! }),
124
134
  })
125
135
  }
136
+ } else if (opts.preset === 'pi') {
137
+ // Same binary as claude/codex, because pi has the same two tiers. The
138
+ // difference is that the api-key side also needs a provider.
139
+ if (!opts.auth) throw new Error('--auth (subscription|api-key) is required for pi')
140
+ if (opts.auth === 'subscription' && !sniffCredentials('pi', opts.home).found) {
141
+ throw new Error(
142
+ 'no pi login found — run `pi` and /login first, or use --auth api-key',
143
+ )
144
+ }
145
+ if (opts.auth === 'api-key' && !opts.provider) {
146
+ throw new Error('--provider is required for pi --auth api-key')
147
+ }
148
+ const providerMeta = opts.provider ? findPiProvider(opts.provider) : undefined
149
+ if (opts.provider && !providerMeta) throw new Error(`unknown pi provider: ${opts.provider}`)
150
+
151
+ // Inherit before pinning: a pair the operator already runs interactively is
152
+ // verified by use. Explicit flags still win over an inherited value.
153
+ const inherited = sniffPiDefaults(opts.home)
154
+ const provider = opts.provider ?? inherited?.provider ?? PI_DEFAULT_PROVIDER
155
+ const model = opts.model ?? inherited?.model ?? PI_DEFAULT_MODEL
156
+ const settingsProvider = opts.model ? provider : (inherited?.provider ?? provider)
157
+
158
+ writes.push({ path: 'zooid.yaml', content: generateZooidYaml({ preset: 'pi' }) })
159
+ writes.push({ path: 'agents/zooid-assistant/AGENTS.md', content: generateAgentsMd() })
160
+ writes.push({
161
+ path: `agents/zooid-assistant/${PI_AGENT_DIR}/settings.json`,
162
+ content: generatePiSettings({ provider: settingsProvider, model }),
163
+ })
164
+
165
+ // PI_CODING_AGENT_DIR is relative on purpose: the agent's cwd is
166
+ // agents/<name> locally and /workspace in a container, and those are the
167
+ // same directory, so one value is correct under both runtimes (spike 1.1).
168
+ const envLines = [`PI_CODING_AGENT_DIR=${PI_AGENT_DIR}`]
169
+ // Only the api-key path writes a key. The subscription path shares the
170
+ // operator's auth.json instead (below) and writes no credential here.
171
+ if (opts.auth === 'api-key' && opts.apiKey) {
172
+ envLines.push(`${providerMeta!.apiKeyEnvVar}=${opts.apiKey}`)
173
+ }
174
+ writes.push({ path: '.env', content: envLines.join('\n') + '\n' })
175
+
176
+ piInherited = inherited
126
177
  } else {
127
178
  throw new Error(`unknown preset: ${String(opts.preset)}`)
128
179
  }
@@ -152,5 +203,40 @@ export async function runInit(opts: InitOptions): Promise<void> {
152
203
  }
153
204
  }
154
205
 
206
+ if (opts.preset === 'pi') {
207
+ if (piInherited) {
208
+ console.log(
209
+ `✓ Inherited defaultProvider=${piInherited.provider} defaultModel=${piInherited.model} from ${piInherited.source}`,
210
+ )
211
+ } else {
212
+ console.log(
213
+ `✓ Pinned defaultProvider=${PI_DEFAULT_PROVIDER} defaultModel=${PI_DEFAULT_MODEL} (pi's own default returns an empty turn)`,
214
+ )
215
+ }
216
+ const s = sniffCredentials('pi', opts.home)
217
+ if (opts.auth === 'subscription' && s.found) {
218
+ // Symlink, not copy: pi rotates OAuth tokens via temp-file-and-rename
219
+ // (spike 1.2), which a symlink survives because it names a path, not an
220
+ // inode. Host-path only — this wizard only ever scaffolds `runtime:
221
+ // local`, so the operator's real ~/.pi/agent/auth.json is the same file
222
+ // the local agent process resolves against.
223
+ const authSource = join(opts.home ?? homedir(), PI_AUTH_FILE)
224
+ const linkPath = join(dir, `agents/zooid-assistant/${PI_AGENT_DIR}/auth.json`)
225
+ if (!existsSync(linkPath)) {
226
+ mkdirSync(dirname(linkPath), { recursive: true })
227
+ symlinkSync(authSource, linkPath)
228
+ }
229
+ console.log(
230
+ `✓ Shared pi login from ${s.path} — the agent uses your subscription via a symlink at ${PI_AGENT_DIR}/auth.json`,
231
+ )
232
+ } else if (s.found) {
233
+ console.log(
234
+ `✓ Found pi login at ${s.path} — the agent gets its own config at ${PI_AGENT_DIR}/ and will not touch it`,
235
+ )
236
+ } else {
237
+ console.warn(`⚠ No pi login detected — the agent uses its own API key from .env`)
238
+ }
239
+ }
240
+
155
241
  console.log('\nNext: zooid dev')
156
242
  }
@@ -54,6 +54,21 @@ describe('collectStatus', () => {
54
54
  ])
55
55
  })
56
56
 
57
+ it('reports the VAPID public key when vapid.json exists in the data dir', async () => {
58
+ writeFileSync(join(dir, 'zooid.yaml'), yaml)
59
+ writeFileSync(join(dir, 'vapid.json'), JSON.stringify({ publicKey: 'BPk', privateKey: 'priv' }))
60
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
61
+ const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
62
+ expect(s.vapidPublicKey).toBe('BPk')
63
+ })
64
+
65
+ it('omits the VAPID key when no daemon has run yet', async () => {
66
+ writeFileSync(join(dir, 'zooid.yaml'), yaml)
67
+ vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })))
68
+ const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448', dataDir: dir })
69
+ expect(s.vapidPublicKey).toBeUndefined()
70
+ })
71
+
57
72
  it('reports daemon down when the AS callback port refuses the connection', async () => {
58
73
  writeFileSync(join(dir, 'zooid.yaml'), yaml)
59
74
  vi.stubGlobal(
@@ -1,8 +1,9 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { dirname } from 'node:path'
2
+ import { dirname, join, resolve } from 'node:path'
3
3
  import chalk from 'chalk'
4
4
  import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
5
5
  import { deriveHomeserverShape } from '../bootstrap/derive.js'
6
+ import { VAPID_FILENAME } from '../push-gateway/vapid.js'
6
7
 
7
8
  export interface StatusFlags {
8
9
  cwd?: string
@@ -15,6 +16,24 @@ export interface StatusReport {
15
16
  tuwunel: { status: 'up' | 'down'; url: string }
16
17
  daemon: { status: 'up' | 'down'; url: string } | { status: 'unknown'; reason: string }
17
18
  agents: { name: string; userId: string; trigger: string }[]
19
+ /** VAPID public key read from `<dataDir>/vapid.json`, when it exists. */
20
+ vapidPublicKey?: string
21
+ }
22
+
23
+ /**
24
+ * Read the daemon's VAPID public key straight off disk. Operators
25
+ * hand-editing a box's config.json need it and can't start a daemon just to
26
+ * ask, so this reads the persisted file rather than starting one.
27
+ */
28
+ export function readVapidPublicKey(dataDir: string): string | undefined {
29
+ try {
30
+ const parsed = JSON.parse(readFileSync(join(dataDir, VAPID_FILENAME), 'utf8')) as {
31
+ publicKey?: string
32
+ }
33
+ return typeof parsed.publicKey === 'string' ? parsed.publicKey : undefined
34
+ } catch {
35
+ return undefined
36
+ }
18
37
  }
19
38
 
20
39
  async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
@@ -29,12 +48,14 @@ async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
29
48
  export async function collectStatus(opts: {
30
49
  cwd: string
31
50
  tuwunelUrl: string
51
+ dataDir?: string
32
52
  }): Promise<StatusReport> {
33
53
  const tuwunelUp = await probe(`${opts.tuwunelUrl}/_matrix/client/versions`)
34
54
  const tuwunel: StatusReport['tuwunel'] = {
35
55
  status: tuwunelUp ? 'up' : 'down',
36
56
  url: opts.tuwunelUrl,
37
57
  }
58
+ const vapidPublicKey = opts.dataDir ? readVapidPublicKey(opts.dataDir) : undefined
38
59
 
39
60
  const found = findConfigFile(opts.cwd)
40
61
  if (!found) {
@@ -42,6 +63,7 @@ export async function collectStatus(opts: {
42
63
  tuwunel,
43
64
  daemon: { status: 'unknown', reason: 'no zooid.yaml' },
44
65
  agents: [],
66
+ ...(vapidPublicKey ? { vapidPublicKey } : {}),
45
67
  }
46
68
  }
47
69
  const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
@@ -67,6 +89,7 @@ export async function collectStatus(opts: {
67
89
  tuwunel,
68
90
  daemon: { status: daemonUp ? 'up' : 'down', url: daemonUrl },
69
91
  agents,
92
+ ...(vapidPublicKey ? { vapidPublicKey } : {}),
70
93
  }
71
94
  }
72
95
 
@@ -89,7 +112,8 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
89
112
  }
90
113
  }
91
114
  const tuwunelUrl = `http://localhost:${port ?? 8448}`
92
- const s = await collectStatus({ cwd, tuwunelUrl })
115
+ const dataDir = resolve(cwd, flags.dataDir ?? './data')
116
+ const s = await collectStatus({ cwd, tuwunelUrl, dataDir })
93
117
  const fmt = (st: 'up' | 'down' | 'unknown'): string =>
94
118
  st === 'up' ? chalk.green('up') : st === 'down' ? chalk.red('down') : chalk.yellow('unknown')
95
119
  process.stdout.write(
@@ -99,6 +123,7 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
99
123
  ...s.agents.map(
100
124
  (a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`,
101
125
  ),
126
+ ...(s.vapidPublicKey ? [`vapid public key: ${s.vapidPublicKey}`] : []),
102
127
  '',
103
128
  ].join('\n'),
104
129
  )
@@ -33,6 +33,7 @@ import {
33
33
  } from '@zooid/context-mcp'
34
34
  import { buildAcpRegistry } from '../build-registry.js'
35
35
  import { prepullImages } from '../prepull-images.js'
36
+ import { mountPushGateway } from '../push-gateway/index.js'
36
37
  import { makeSyncCursorStore } from './sync-cursors.js'
37
38
  import { shouldBindHttpListener } from './pull-wiring.js'
38
39
 
@@ -79,6 +80,8 @@ export interface StartDaemonOpts {
79
80
  export interface DaemonHandle {
80
81
  port: number
81
82
  agentNames: string[]
83
+ /** VAPID public key for web push, when the gateway bound (appservice mode with a data dir). */
84
+ vapidPublicKey?: string
82
85
  stop(): Promise<void>
83
86
  whenStopped: Promise<void>
84
87
  }
@@ -166,6 +169,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
166
169
 
167
170
  const matrix = findMatrixTransport(config)
168
171
  let port: number
172
+ let vapidPublicKey: string | undefined
169
173
 
170
174
  if (matrix) {
171
175
  const mode = matrix.transport.mode ?? 'appservice'
@@ -232,6 +236,16 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
232
236
  })
233
237
  if (shouldBindHttpListener(mode)) {
234
238
  const requestedPort = matrix.transport.port ?? 9000
239
+ // The gateway rides the appservice listener, not webStatic: webStatic
240
+ // exists only under `zooid dev`, and on a deployed box Caddy serves the
241
+ // dist directly with the daemon out of the serving path. This is the
242
+ // one HTTP surface bound in both modes.
243
+ if (dataDir) {
244
+ vapidPublicKey = mountPushGateway(transport.app, {
245
+ dataDir,
246
+ subject: `https://${serverName}`,
247
+ }).publicKey
248
+ }
235
249
  // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
236
250
  // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
237
251
  // events back to host.docker.internal:<port>.
@@ -347,5 +361,5 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
347
361
  process.on('SIGTERM', () => handler('SIGTERM'))
348
362
  }
349
363
 
350
- return { port, agentNames, stop, whenStopped }
364
+ return { port, agentNames, vapidPublicKey, stop, whenStopped }
351
365
  }