@xingular/harness 0.1.6 → 2.0.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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,97 @@
1
+ import { takeChars } from '../core/text'
2
+ import { findRun, loadRun, readEvents } from './runStore'
3
+
4
+ export const SPAWN_SUBAGENT = 'spawn_subagent'
5
+ export const WAIT_SUBAGENTS = 'wait_subagents'
6
+ export const SUBAGENT_RELATION = 'subagent'
7
+
8
+ /** How deep subagents may nest when the caller does not say. */
9
+ export const DEFAULT_SUBAGENT_DEPTH = 1
10
+ export const MAX_SUBAGENT_DEPTH = 3
11
+
12
+ export interface SubagentOptions {
13
+ /** Maximum consecutive native subagent levels below a caller (1 to 3). */
14
+ maxDepth?: number
15
+ }
16
+
17
+ export function subagentDepth(options: SubagentOptions | undefined): number {
18
+ const value = Math.trunc(options?.maxDepth ?? DEFAULT_SUBAGENT_DEPTH)
19
+ return Math.min(MAX_SUBAGENT_DEPTH, Math.max(1, Number.isFinite(value) ? value : DEFAULT_SUBAGENT_DEPTH))
20
+ }
21
+
22
+ export interface SubagentInput {
23
+ parentRunId: string
24
+ brief: string
25
+ tools?: string[]
26
+ wait?: boolean
27
+ }
28
+
29
+ export interface SubagentSelection {
30
+ parentRunId: string
31
+ runIds?: string[]
32
+ }
33
+
34
+ export interface SubagentResult {
35
+ runId: string
36
+ status: string
37
+ text: string
38
+ terminalReason: string | null
39
+ }
40
+
41
+ export interface SubagentReceipt {
42
+ version: 1
43
+ ownerRunId: string
44
+ spawned?: string[]
45
+ delivered?: string[]
46
+ }
47
+
48
+ export const SUBAGENT_RECEIPT = 'xingularSubagents'
49
+ export const SUBAGENT_OWNER = 'xingular-subagent-owner'
50
+ export const SUBAGENT_RESULTS = 'xingular-subagent-results'
51
+
52
+ /** Read explicit native metadata, including entries retained before compaction. */
53
+ export function subagentHistory(entries: readonly unknown[]): { owners: string[]; receipts: SubagentReceipt[] } {
54
+ const owners: string[] = []
55
+ const receipts: SubagentReceipt[] = []
56
+ for (const raw of entries) {
57
+ if (!raw || typeof raw !== 'object') continue
58
+ const entry = raw as Record<string, any>
59
+ if (entry.type === 'custom' && entry.customType === SUBAGENT_OWNER && typeof entry.data?.runId === 'string') owners.push(entry.data.runId)
60
+ const message = entry.type === 'message' ? entry.message : entry
61
+ const receipt = message?.details?.[SUBAGENT_RECEIPT]
62
+ if (!receipt || receipt.version !== 1 || typeof receipt.ownerRunId !== 'string') continue
63
+ const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((id) => typeof id === 'string')
64
+ if ((receipt.spawned !== undefined && !strings(receipt.spawned)) || (receipt.delivered !== undefined && !strings(receipt.delivered))) continue
65
+ receipts.push(receipt)
66
+ }
67
+ return { owners, receipts }
68
+ }
69
+
70
+ /** Native nesting is independent of a host's other uses of the generic caller depth. */
71
+ export function subagentNesting(root: string, run: { relation?: string | null; parentRunId?: string | null }): number {
72
+ let current: typeof run = run
73
+ let depth = 0
74
+ const visited = new Set<string>()
75
+ while (current.relation === SUBAGENT_RELATION) {
76
+ const parent = current.parentRunId
77
+ if (!parent || visited.has(parent)) return MAX_SUBAGENT_DEPTH
78
+ visited.add(parent)
79
+ depth++
80
+ const record = findRun(root, parent)
81
+ if (!record) return MAX_SUBAGENT_DEPTH
82
+ current = record
83
+ }
84
+ return depth
85
+ }
86
+
87
+ /** The child's final reply, or why it ended without one. */
88
+ export function subagentReport(root: string, runId: string): string {
89
+ const run = loadRun(root, runId)
90
+ const reply = readEvents(root, runId).filter((event) => event.type === 'message.completed' && (event.payload as { role?: unknown }).role === 'assistant').at(-1)
91
+ const text = typeof (reply?.payload as { text?: unknown } | undefined)?.text === 'string' ? takeChars((reply!.payload as { text: string }).text, 16_000) : ''
92
+ const lines = [`Subagent run ${runId} · ${run.status}`]
93
+ if (text) lines.push(`Result:\n${text}`)
94
+ else if (run.status === 'completed') lines.push('It finished without a written reply.')
95
+ if (run.status !== 'completed' && run.terminalReason) lines.push(`Stopped because: ${run.terminalReason}`)
96
+ return lines.join('\n')
97
+ }
@@ -291,11 +291,16 @@ export function create(root: string, kind: string, rawName: string): RegistrySta
291
291
 
292
292
  /** Imports complete user-owned skill folders without replacing existing or built-in items. */
293
293
  export function importSkills(root: string, bundles: SkillBundleInput[]): RegistryState {
294
+ return importItems(root, 'skills', bundles)
295
+ }
296
+
297
+ /** Imports inert registry resources. Prompts and rules are single UTF-8 Markdown documents. */
298
+ export function importItems(root: string, kind: RegistryKind, bundles: SkillBundleInput[]): RegistryState {
294
299
  if (!Array.isArray(bundles) || bundles.length === 0 || bundles.length > MAX_SKILL_BUNDLES) {
295
300
  throw invalid(`Import between 1 and ${MAX_SKILL_BUNDLES} skills at a time.`)
296
301
  }
297
302
 
298
- const directory = kindDir(root, 'skills')
303
+ const directory = kindDir(root, kind)
299
304
  mkdirSync(directory, { recursive: true })
300
305
  const existing = new Set(list(root).map((item) => item.id))
301
306
  const seen = new Set<string>()
@@ -308,10 +313,10 @@ export function importSkills(root: string, bundles: SkillBundleInput[]): Registr
308
313
  if (name.length > 64) throw invalid('Use a skill name with at most 64 letters or numbers.')
309
314
  const slug = Array.from(name.toLowerCase(), (character) => (/^[a-z0-9]$/.test(character) ? character : '-')).join('').split('-').filter(Boolean).join('-')
310
315
  if (!slug || slug.length > 64) throw invalid('Use a skill name with at most 64 letters or numbers.')
311
- const id = `skills/${slug}`
316
+ const id = `${kind}/${slug}`
312
317
  if (existing.has(id) || seen.has(id)) throw invalid(`The skill “${name}” already exists.`)
313
318
  try {
314
- lstatSync(join(directory, slug))
319
+ lstatSync(join(directory, kind === 'skills' ? slug : `${slug}.md`))
315
320
  throw invalid(`The skill “${name}” already exists.`)
316
321
  } catch (error) {
317
322
  if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
@@ -350,7 +355,8 @@ export function importSkills(root: string, bundles: SkillBundleInput[]): Registr
350
355
  if (file.encoding === 'base64' && bytes.toString('base64') !== file.content) throw invalid(`The file “${relative}” is not valid base64.`)
351
356
  files.set(relative, bytes)
352
357
  }
353
- if (!files.has('SKILL.md')) throw invalid(`The skill “${name}” must include SKILL.md at its root.`)
358
+ if (kind === 'skills' && !files.has('SKILL.md')) throw invalid(`The skill “${name}” must include SKILL.md at its root.`)
359
+ if (kind !== 'skills' && (bundle.files.length !== 1 || bundle.files[0].encoding !== 'utf8' || bundle.files[0].path !== 'CONTENT.md')) throw invalid('A prompt or rule must contain one UTF-8 CONTENT.md file.')
354
360
  return { name, slug, files }
355
361
  })
356
362
 
@@ -366,8 +372,8 @@ export function importSkills(root: string, bundles: SkillBundleInput[]): Registr
366
372
  }
367
373
  }
368
374
  for (const bundle of prepared) {
369
- const target = join(directory, bundle.slug)
370
- renameSync(join(staging, bundle.slug), target)
375
+ const target = join(directory, kind === 'skills' ? bundle.slug : `${bundle.slug}.md`)
376
+ renameSync(kind === 'skills' ? join(staging, bundle.slug) : join(staging, bundle.slug, 'CONTENT.md'), target)
371
377
  installed.push(target)
372
378
  }
373
379
  } catch (error) {
@@ -383,7 +389,7 @@ export function importSkills(root: string, bundles: SkillBundleInput[]): Registr
383
389
  * Writes a skill, prompt, or rule (an accepted proposal), keeping an existing item's location.
384
390
  * The previous content and the new content are recorded as Wiki revisions of the item.
385
391
  */
386
- export function writeItem(root: string, kind: string, name: string, description: string, body: string, reason: string): string {
392
+ export function writeItem(root: string, kind: string, name: string, description: string, body: string, reason: string, actor = 'user'): string {
387
393
  const plural = kind === 'skill' ? 'skills' : kind === 'prompt' ? 'prompts' : kind === 'rule' ? 'rules' : null
388
394
  if (!plural) throw invalid('Only skills, prompts, and rules can be written.')
389
395
  const stem = store.slug(name)
@@ -395,13 +401,13 @@ export function writeItem(root: string, kind: string, name: string, description:
395
401
  const wiki = store.wikiDir(root)
396
402
  if (existing) {
397
403
  try {
398
- store.snapshot(wiki, id, readFileSync(existing.path, 'utf8'), 'user', 'Before accepted proposal')
404
+ store.snapshot(wiki, id, readFileSync(existing.path, 'utf8'), actor, 'Before accepted proposal')
399
405
  } catch {
400
406
  // Nothing readable to keep.
401
407
  }
402
408
  }
403
409
  atomicWrite(path, text)
404
- store.snapshot(wiki, id, text, 'user', reason)
405
- store.log(wiki, `- ${now()} · user · ${existing ? 'updated' : 'created'} \`${id}\` · ${reason}`)
410
+ store.snapshot(wiki, id, text, actor, reason)
411
+ store.log(wiki, `- ${now()} · ${actor} · ${existing ? 'updated' : 'created'} \`${id}\` · ${reason}`)
406
412
  return id
407
413
  }
@@ -0,0 +1,225 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import type { AccountsOverview, AccountView } from '../accounts/store'
7
+ import { memorySecretStore } from '../accounts/secrets'
8
+ import { createHarness, type Harness } from '../harness'
9
+ import { rootWithProject, writeFile } from '../../tests/helpers'
10
+ import { SOCKET_ENV, TOKEN_ENV, socketCall, type SocketConnection } from './client'
11
+ import { accountDetails, accountRows, resetIn, runAccountsMenu, type AccountsCall, type AccountsUi, type SessionAccount } from './accountsMenu'
12
+
13
+ const open: Harness[] = []
14
+ afterEach(async () => {
15
+ for (const harness of open.splice(0)) await harness.close()
16
+ })
17
+
18
+ /**
19
+ * A person at the terminal. Each answer picks the first option that contains it (select), or
20
+ * is the typed text (input), or is the yes/no (confirm). A select with no matching option fails
21
+ * the test, so a changed menu label shows up here.
22
+ */
23
+ function person(answers: (string | boolean | undefined)[]) {
24
+ const shown: string[] = []
25
+ const said: { message: string; level?: string }[] = []
26
+ const next = () => answers.shift()
27
+ const ui: AccountsUi = {
28
+ select: async (title, options) => {
29
+ shown.push(title)
30
+ const answer = next()
31
+ if (answer === undefined) return undefined
32
+ const found = options.find((option) => option.includes(String(answer)))
33
+ if (!found) throw new Error(`No option containing "${answer}" in: ${options.join(' | ')}`)
34
+ return found
35
+ },
36
+ confirm: async (title) => {
37
+ shown.push(title)
38
+ return next() === true
39
+ },
40
+ input: async (title) => {
41
+ shown.push(title)
42
+ const answer = next()
43
+ return typeof answer === 'string' ? answer : undefined
44
+ },
45
+ notify: (message, level) => void said.push({ message, level })
46
+ }
47
+ return { ui, shown, said, remaining: () => answers.length }
48
+ }
49
+
50
+ function view(id: string, label: string, overrides: Partial<AccountView> = {}): AccountView {
51
+ return {
52
+ id,
53
+ provider: 'pi',
54
+ label,
55
+ authKind: 'subscription',
56
+ createdAt: '2026-10-01T00:00:00.000Z',
57
+ isDefault: false,
58
+ state: { health: 'unknown', signedIn: null, authMethod: null, plan: null, identity: null, limits: [], lastError: null, lastCheckedAt: null, cliVersion: null, capabilities: [], piProviders: [] },
59
+ ...overrides
60
+ }
61
+ }
62
+
63
+ describe('the accounts menu, without a terminal', () => {
64
+ it('numbers each account, marks the default and this session, and shows its health', () => {
65
+ const overview: AccountsOverview = {
66
+ accounts: [view('a1', 'Main', { isDefault: true, state: { ...view('a1', 'Main').state, health: 'limited' } }), view('a2', 'Spare')],
67
+ pools: [],
68
+ defaults: { pi: 'a1' }
69
+ }
70
+ const rows = accountRows(overview, { accountId: 'a2', label: 'Spare' } satisfies SessionAccount)
71
+ expect(rows.map((row) => row.label)).toEqual(['1. ● Main · Limit reached', '2. ○ Spare · Not checked · this session'])
72
+ expect(rows[1].account.id).toBe('a2')
73
+ })
74
+
75
+ it('says what the account can do and that providers do not report usage when none is reported', () => {
76
+ const text = accountDetails(view('a1', 'Main', { state: { ...view('a1', 'Main').state, health: 'auth_expired', piProviders: ['anthropic'], lastError: { category: 'rate_limited', message: 'Slow down.', at: '2026-10-01T00:00:00.000Z', resetsAt: null } } }))
77
+ expect(text).toContain('Health: Sign in again')
78
+ expect(text).toContain('Providers: anthropic')
79
+ expect(text).toContain('Last error: Slow down.')
80
+ expect(text).toContain('Pi providers do not report them.')
81
+ })
82
+
83
+ it('formats a reset time as a short countdown', () => {
84
+ const now = Date.parse('2026-10-01T00:00:00.000Z')
85
+ expect(resetIn('2026-10-01T00:05:00.000Z', now)).toBe('in 5m')
86
+ expect(resetIn('2026-10-01T02:30:00.000Z', now)).toBe('in 2h 30m')
87
+ expect(resetIn('2026-10-03T00:00:00.000Z', now)).toBe('in 2d')
88
+ expect(resetIn('2026-09-30T00:00:00.000Z', now)).toBe('now')
89
+ })
90
+ })
91
+
92
+ async function session() {
93
+ const root = rootWithProject()
94
+ const base = mkdtempSync(join(tmpdir(), 'accounts-menu-'))
95
+ writeFile(join(root, 'registry/rules/tests.md'), '---\nname: Run tests\ndescription: Always test.\n---\nAlways run the tests.\n')
96
+ const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore(), piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' } })
97
+ open.push(harness)
98
+ const main = harness.accounts.create('Main', 'apiKey').accounts[0]
99
+ const spare = harness.accounts.create('Spare', 'apiKey').accounts.find((account) => account.label === 'Spare')!
100
+ const launch = await harness.terminals.prepare({ sessionId: 'term-1', accountId: main.id, cwd: base, sessionFolder: join(root, 'sessions', 'term-1') })
101
+ const connection: SocketConnection = { socket: String(launch.env[SOCKET_ENV]), token: String(launch.env[TOKEN_ENV]) }
102
+ // The extension claims the key before the model runs; the menu uses it for every change.
103
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
104
+ const call: AccountsCall = (method, params) => socketCall(connection, method, params ?? {}, 5_000, key)
105
+ return { harness, main, spare, connection, key, call }
106
+ }
107
+
108
+ describe('the accounts menu, over the session socket', () => {
109
+ it('refuses account calls from the shell token, and serves them to the extension with its key', async () => {
110
+ const { connection, key, main, harness } = await session()
111
+ await expect(socketCall(connection, 'accounts.list')).rejects.toMatchObject({ code: 'unauthorized' })
112
+ await expect(socketCall(connection, 'accounts.list', {}, 5_000, 'guessed')).rejects.toMatchObject({ code: 'unauthorized' })
113
+ await expect(socketCall(connection, 'accounts.deviceId')).rejects.toMatchObject({ code: 'unauthorized' })
114
+ const listed = await socketCall<AccountsOverview>(connection, 'accounts.list', {}, 5_000, key)
115
+ expect(listed.accounts.map((account) => account.id)).toContain(main.id)
116
+ expect(await socketCall<{ accountId: string; label: string }>(connection, 'accounts.session', {}, 5_000, key)).toEqual({ accountId: main.id, label: 'Main' })
117
+ expect(await socketCall(connection, 'accounts.deviceId', {}, 5_000, key)).toBe(await harness.accounts.installationDeviceId())
118
+ })
119
+
120
+ it('will not remove an account a running terminal is using, this session included', async () => {
121
+ const { call, main, spare, harness } = await session()
122
+ await expect(call('accounts.remove', { accountId: main.id })).rejects.toMatchObject({ code: 'account_in_use' })
123
+ expect(harness.accounts.find(main.id).id).toBe(main.id)
124
+ // An account with no terminal on it is removed as usual.
125
+ await call('accounts.remove', { accountId: spare.id })
126
+ expect(() => harness.accounts.find(spare.id)).toThrow()
127
+ })
128
+
129
+ it('makes an account the default from the menu', async () => {
130
+ const { call, harness, spare, main } = await session()
131
+ const { ui, said } = person(['Spare', 'Make default', 'Back', 'Done'])
132
+ expect(await runAccountsMenu(ui, call, { accountId: main.id, label: 'Main' })).toBeUndefined()
133
+ expect(harness.accounts.overview().defaults.pi).toBe(spare.id)
134
+ expect(said.map((entry) => entry.message)).toContain('Spare is the default for new sessions.')
135
+ })
136
+
137
+ it('switches this session to another account by ending Pi, and leaves the choice for the launcher', async () => {
138
+ const { call, harness, spare, main } = await session()
139
+ const { ui } = person(['Spare', 'Use for this session', true])
140
+ const outcome = await runAccountsMenu(ui, call, { accountId: main.id, label: 'Main' })
141
+ expect(outcome).toBe('relaunch')
142
+ expect(harness.terminals.takeRelaunch('term-1')).toEqual({ accountId: spare.id, nativeSessionId: null })
143
+ // Taken once: a second read finds nothing.
144
+ expect(harness.terminals.takeRelaunch('term-1')).toBeNull()
145
+ })
146
+
147
+ it('does not switch when the person declines the confirmation', async () => {
148
+ const { call, harness, spare, main } = await session()
149
+ const { ui } = person(['Spare', 'Use for this session', false, 'Back', 'Done'])
150
+ expect(await runAccountsMenu(ui, call, { accountId: main.id, label: 'Main' })).toBeUndefined()
151
+ expect(harness.terminals.takeRelaunch('term-1')).toBeNull()
152
+ expect(harness.accounts.find(spare.id).id).toBe(spare.id)
153
+ })
154
+ })
155
+
156
+ describe('signing in from the accounts menu', () => {
157
+ it('shows structured OAuth choices and returns the selected provider choice ID', async () => {
158
+ const overview: AccountsOverview = { accounts: [view('a1', 'Main')], pools: [], defaults: { pi: 'a1' } }
159
+ const answered: Record<string, unknown>[] = []
160
+ const statuses = [
161
+ { loginId: 'L2', revision: 1, status: 'running', events: [], prompt: { type: 'prompt', promptId: 'P2', prompt: { type: 'select', message: 'Select OpenAI Codex login method:', options: [{ id: 'device_code', label: 'Device code login' }, { id: 'browser', label: 'Browser login' }] } }, success: null, error: null, accountId: 'a1', provider: 'openai-codex', method: 'oauth' },
162
+ { loginId: 'L2', revision: 2, status: 'completed', events: [], prompt: null, success: true, error: null, accountId: 'a1', provider: 'openai-codex', method: 'oauth' }
163
+ ]
164
+ const call = (async (method: string, params?: Record<string, unknown>) => {
165
+ switch (method) {
166
+ case 'accounts.list':
167
+ return overview
168
+ case 'accounts.providers':
169
+ return [{ id: 'openai-codex', name: 'OpenAI Codex', oauth: true, oauthLabel: 'Sign in with ChatGPT', apiKey: false }]
170
+ case 'accounts.loginStart':
171
+ return { loginId: 'L2' }
172
+ case 'accounts.loginStatus':
173
+ return statuses.shift()
174
+ case 'accounts.loginRespond':
175
+ answered.push(params!)
176
+ return {}
177
+ default:
178
+ throw new Error(`unexpected ${method}`)
179
+ }
180
+ }) as unknown as AccountsCall
181
+ const script = person(['Main', 'Sign in', 'OpenAI Codex', 'Device code login', 'Back', 'Done'])
182
+
183
+ expect(await runAccountsMenu(script.ui, call, { accountId: 'a1', label: 'Main' })).toBeUndefined()
184
+
185
+ expect(answered).toEqual([{ loginId: 'L2', promptId: 'P2', value: 'device_code' }])
186
+ expect(script.shown).toContain('Select OpenAI Codex login method:')
187
+ })
188
+
189
+ it('follows a browser sign-in, answers its prompt, and reports the result', async () => {
190
+ const overview: AccountsOverview = { accounts: [view('a1', 'Main')], pools: [], defaults: { pi: 'a1' } }
191
+ const answered: Record<string, unknown>[] = []
192
+ const statuses = [
193
+ { loginId: 'L1', revision: 1, status: 'running', events: [{ type: 'auth_url', url: 'https://auth.example/start' }], prompt: null, success: null, error: null, accountId: 'a1', provider: 'anthropic', method: 'oauth' },
194
+ { loginId: 'L1', revision: 2, status: 'running', events: [], prompt: { type: 'prompt', promptId: 'P1', prompt: { type: 'secret', message: 'Paste the code', placeholder: null, options: null } }, success: null, error: null, accountId: 'a1', provider: 'anthropic', method: 'oauth' },
195
+ { loginId: 'L1', revision: 3, status: 'completed', events: [], prompt: null, success: true, error: null, accountId: 'a1', provider: 'anthropic', method: 'oauth' }
196
+ ]
197
+ const call = (async (method: string, params?: Record<string, unknown>) => {
198
+ switch (method) {
199
+ case 'accounts.list':
200
+ return overview
201
+ case 'accounts.session':
202
+ return { accountId: 'a1', label: 'Main' }
203
+ case 'accounts.providers':
204
+ return [{ id: 'anthropic', name: 'Anthropic', oauth: true, oauthLabel: 'Claude', apiKey: false }]
205
+ case 'accounts.loginStart':
206
+ expect(params).toEqual({ accountId: 'a1', provider: 'anthropic' })
207
+ return { loginId: 'L1' }
208
+ case 'accounts.loginStatus':
209
+ return statuses.shift()
210
+ case 'accounts.loginRespond':
211
+ answered.push(params!)
212
+ return {}
213
+ default:
214
+ throw new Error(`unexpected ${method}`)
215
+ }
216
+ }) as unknown as AccountsCall
217
+ // Account, then Sign in, then the provider; one sign-in method means no second pick. The
218
+ // browser step is answered with "Check status", and the prompt is typed as '123'.
219
+ const script = person(['Main', 'Sign in', 'Anthropic', 'Check status', '123', 'Back', 'Done'])
220
+ expect(await runAccountsMenu(script.ui, call, { accountId: 'a1', label: 'Main' })).toBeUndefined()
221
+ expect(answered).toEqual([{ loginId: 'L1', promptId: 'P1', value: '123' }])
222
+ expect(script.said.map((entry) => entry.message)).toContain('Signed in.')
223
+ expect(script.remaining()).toBe(0)
224
+ })
225
+ })