@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
@@ -1,7 +1,7 @@
1
1
  // The accounts service: everything that needs the secret store, the Pi runtime, or a notice.
2
2
  // Credentials go to the embedding application's secret store; nothing here writes one to the
3
3
  // harness's own files, except a terminal session's `auth.json` while that terminal runs.
4
- import { chmodSync, rmSync } from 'node:fs'
4
+ import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
5
5
  import { randomUUID } from 'node:crypto'
6
6
  import { join } from 'node:path'
7
7
 
@@ -38,6 +38,36 @@ export interface ModelOption {
38
38
 
39
39
  type Credential = Record<string, unknown> & { type: 'api_key' | 'oauth' }
40
40
 
41
+ /** The lease files of an account: one per process that has a terminal on it. */
42
+ const leaseFolder = (dir: string, accountId: string): string => join(dir, accountId, 'terminal-leases')
43
+
44
+ /** Whether a process with this id exists. One that exists but belongs to another user still counts. */
45
+ function processAlive(pid: number): boolean {
46
+ try {
47
+ process.kill(pid, 0)
48
+ return true
49
+ } catch (error) {
50
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
51
+ }
52
+ }
53
+
54
+ /** The credentials in an auth.json, or null when there is no readable file. */
55
+ function readAuth(path: string): Record<string, Credential> | null {
56
+ try {
57
+ const parsed = readJson<Record<string, Credential>>(path)
58
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ /** Compares two credentials by their fields, not their key order, which Pi may change when it rewrites the file. */
65
+ function sameCredential(a: Credential | undefined, b: Credential | undefined): boolean {
66
+ if (a === b) return true
67
+ if (!a || !b) return false
68
+ return JSON.stringify(a, Object.keys(a).sort()) === JSON.stringify(b, Object.keys(b).sort())
69
+ }
70
+
41
71
  function validProvider(provider: string): string {
42
72
  if (provider && provider.length <= 64 && /^[A-Za-z0-9._-]+$/.test(provider)) return provider
43
73
  throw invalid('Invalid provider name.')
@@ -45,8 +75,26 @@ function validProvider(provider: string): string {
45
75
 
46
76
  interface PendingLogin {
47
77
  accountId: string
78
+ provider: string
79
+ method: 'oauth' | 'api_key'
48
80
  cancel: () => void
49
81
  prompts: Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>
82
+ revision: number
83
+ events: Record<string, unknown>[]
84
+ prompt: Record<string, unknown> | null
85
+ }
86
+
87
+ export interface AccountLoginSnapshot {
88
+ accountId: string
89
+ loginId: string
90
+ provider: string
91
+ method: string
92
+ revision: number
93
+ status: 'running' | 'completed' | 'failed' | 'cancelled'
94
+ events: Record<string, unknown>[]
95
+ prompt: Record<string, unknown> | null
96
+ success: boolean | null
97
+ error: string | null
50
98
  }
51
99
 
52
100
  interface Probe {
@@ -71,7 +119,12 @@ export class Accounts {
71
119
  private readonly bus: Bus
72
120
  private readonly runtimes = new Map<string, Promise<ModelRuntime>>()
73
121
  private readonly logins = new Map<string, PendingLogin>()
122
+ private readonly latestLogins = new Map<string, AccountLoginSnapshot>()
123
+ /** How many of this process's terminals use each account's auth.json. */
74
124
  private readonly terminals = new Map<string, number>()
125
+ /** The credentials this process wrote to, or found in, each account's auth.json when its first terminal began. */
126
+ private readonly terminalSnapshots = new Map<string, Record<string, Credential>>()
127
+ private installationDeviceIdPromise: Promise<string> | null = null
75
128
 
76
129
  constructor(options: AccountsOptions) {
77
130
  this.dir = options.dir
@@ -157,6 +210,25 @@ export class Accounts {
157
210
  }
158
211
  }
159
212
 
213
+ /** Pi's OAuth device ID belongs to this Harness installation, not to one isolated account. */
214
+ installationDeviceId(): Promise<string> {
215
+ if (!this.installationDeviceIdPromise) {
216
+ this.installationDeviceIdPromise = this.loadInstallationDeviceId().catch((error: unknown) => {
217
+ this.installationDeviceIdPromise = null
218
+ throw error
219
+ })
220
+ }
221
+ return this.installationDeviceIdPromise
222
+ }
223
+
224
+ private async loadInstallationDeviceId(): Promise<string> {
225
+ const { SettingsManager } = await import('@earendil-works/pi-coding-agent')
226
+ const settings = SettingsManager.create(process.cwd(), env.ensureSharedDir(this.dir))
227
+ const deviceId = settings.getOrCreateDeviceId()
228
+ await settings.flush()
229
+ return deviceId
230
+ }
231
+
160
232
  // ---- The Pi runtime ---------------------------------------------------------------------
161
233
 
162
234
  /** The lazily loaded Pi runtime of one account: its models and its credentials. Pi's own `~/.pi` is never used. */
@@ -250,7 +322,7 @@ export class Accounts {
250
322
  const keepLimitError = current.lastError !== null && store.isLimitCategory(current.lastError.category) && store.limitErrorActive(current.lastError)
251
323
  if (probe.signedIn === true && !keepLimitError) current.lastError = null
252
324
  } else {
253
- current.lastError = { category: 'unknown', message: store.scrub(failure ?? ''), at: now(), resetsAt: null }
325
+ current.lastError = { category: classifyMessage(failure ?? '') ?? 'unknown', message: store.scrub(failure ?? ''), at: now(), resetsAt: null }
254
326
  }
255
327
  })
256
328
  if (previous !== state.health) store.audit(this.dir, account.id, 'health.changed', `${previous} → ${state.health} (status check)`)
@@ -295,6 +367,8 @@ export class Accounts {
295
367
  /** Deletes the account: removes its stored credentials and its runtime folder. Sessions pinned to it ask for another account next time they start. */
296
368
  async remove(accountId: string): Promise<AccountsOverview> {
297
369
  const account = this.find(accountId)
370
+ for (const login of this.logins.values()) if (login.accountId === accountId) login.cancel()
371
+ this.latestLogins.delete(accountId)
298
372
  await this.forgetCredentials(account).catch(() => undefined)
299
373
  await this.secrets.delete(secretKey(account.id, 'apiKey')).catch(() => undefined)
300
374
  store.forgetAccount(this.dir, account)
@@ -354,7 +428,7 @@ export class Accounts {
354
428
  return this.overview()
355
429
  }
356
430
 
357
- private finishLogin(loginId: string, success: boolean, error: string | null): void {
431
+ private finishLogin(loginId: string, success: boolean, error: string | null, cancelled = false): void {
358
432
  const login = this.logins.get(loginId)
359
433
  if (!login) return
360
434
  this.logins.delete(loginId)
@@ -363,7 +437,25 @@ export class Accounts {
363
437
  store.audit(this.dir, login.accountId, success ? 'login.completed' : 'login.failed', error ?? '')
364
438
  // A fresh sign-in starts clean; a limit that still applies is reported again by the next run.
365
439
  if (success) store.updateState(this.dir, login.accountId, (current) => void (current.lastError = null))
366
- this.bus.emit({ type: 'accounts.login', accountId: login.accountId, loginId, success, error })
440
+ login.revision += 1
441
+ const status: AccountLoginSnapshot['status'] = success ? 'completed' : cancelled ? 'cancelled' : 'failed'
442
+ if (this.latestLogins.get(login.accountId)?.loginId === loginId) {
443
+ this.latestLogins.set(login.accountId, {
444
+ accountId: login.accountId,
445
+ loginId,
446
+ provider: login.provider,
447
+ method: login.method,
448
+ revision: login.revision,
449
+ status,
450
+ // A terminal result is enough to reconcile the client. In particular, do not retain a
451
+ // device code after the attempt ends.
452
+ events: [],
453
+ prompt: null,
454
+ success,
455
+ error
456
+ })
457
+ }
458
+ this.bus.emit({ type: 'accounts.login', accountId: login.accountId, loginId, revision: login.revision, success, error })
367
459
  try {
368
460
  void this.refreshOne(this.find(login.accountId)).catch(() => undefined)
369
461
  } catch {
@@ -379,12 +471,55 @@ export class Accounts {
379
471
  const account = this.find(accountId)
380
472
  if (!provider) throw invalid('Choose which Pi provider to sign in to.')
381
473
  store.audit(this.dir, account.id, 'login.started', `${method ?? 'oauth'} · ${provider}`)
382
- const runtime = await this.runtime(account)
383
474
  const loginId = randomUUID()
384
475
  const controller = new AbortController()
385
- const login: PendingLogin = { accountId: account.id, prompts: new Map(), cancel: () => controller.abort() }
476
+ const login: PendingLogin = {
477
+ accountId: account.id,
478
+ provider,
479
+ method: method === 'api_key' ? 'api_key' : 'oauth',
480
+ prompts: new Map(),
481
+ cancel: () => controller.abort(),
482
+ revision: 0,
483
+ events: [],
484
+ prompt: null
485
+ }
386
486
  this.logins.set(loginId, login)
387
- const event = (payload: Record<string, unknown>): void => this.bus.emit({ type: 'accounts.loginEvent', accountId: account.id, loginId, event: payload })
487
+ this.latestLogins.set(account.id, {
488
+ accountId: account.id,
489
+ loginId,
490
+ provider: login.provider,
491
+ method: login.method,
492
+ revision: 0,
493
+ status: 'running',
494
+ events: [],
495
+ prompt: null,
496
+ success: null,
497
+ error: null
498
+ })
499
+ const event = (payload: Record<string, unknown>): void => {
500
+ login.revision += 1
501
+ if (payload.type === 'prompt') login.prompt = payload
502
+ else if (payload.type === 'promptClosed' && login.prompt?.promptId === payload.promptId) login.prompt = null
503
+ else {
504
+ login.events.push(payload)
505
+ if (login.events.length > 50) login.events.shift()
506
+ }
507
+ if (this.latestLogins.get(account.id)?.loginId === loginId) {
508
+ this.latestLogins.set(account.id, {
509
+ accountId: account.id,
510
+ loginId,
511
+ provider: login.provider,
512
+ method: login.method,
513
+ revision: login.revision,
514
+ status: 'running',
515
+ events: login.events.map((step) => ({ ...step })),
516
+ prompt: login.prompt ? { ...login.prompt } : null,
517
+ success: null,
518
+ error: null
519
+ })
520
+ }
521
+ this.bus.emit({ type: 'accounts.loginEvent', accountId: account.id, loginId, revision: login.revision, event: payload })
522
+ }
388
523
  const interaction = {
389
524
  signal: controller.signal,
390
525
  notify: (notification: Record<string, unknown>) => event(notification),
@@ -400,24 +535,53 @@ export class Accounts {
400
535
  event({ type: 'prompt', promptId, prompt: { type: prompt.type, message: prompt.message, placeholder: prompt.placeholder ?? null, options: prompt.options ?? null } })
401
536
  })
402
537
  }
403
- runtime
404
- .login(provider, method === 'api_key' ? 'api_key' : 'oauth', interaction as never)
538
+ void this.runtime(account)
539
+ .then(async (runtime) => {
540
+ if (login.method !== 'oauth') return runtime.login(provider, login.method, interaction as never)
541
+ const deviceId = await this.installationDeviceId()
542
+ return runtime.login(provider, login.method, interaction as never, { getDeviceId: () => deviceId })
543
+ })
405
544
  .then(() => this.finishLogin(loginId, true, null))
406
- .catch((error: unknown) => this.finishLogin(loginId, false, controller.signal.aborted ? 'Sign-in was cancelled.' : error instanceof Error ? error.message : String(error)))
545
+ .catch((error: unknown) => this.finishLogin(
546
+ loginId,
547
+ false,
548
+ controller.signal.aborted ? 'Sign-in was cancelled.' : error instanceof Error ? error.message : String(error),
549
+ controller.signal.aborted
550
+ ))
407
551
  return { loginId }
408
552
  }
409
553
 
554
+ /** Current in-memory sign-in progress for one account; codes and prompts are never written to disk. */
555
+ loginStatus(accountId: string): AccountLoginSnapshot | null {
556
+ this.find(accountId)
557
+ const snapshot = this.latestLogins.get(accountId)
558
+ return snapshot ? structuredClone(snapshot) : null
559
+ }
560
+
410
561
  loginRespond(loginId: string, promptId: string, value: string | null): void {
411
562
  const login = this.logins.get(loginId)
412
563
  const prompt = login?.prompts.get(promptId)
413
564
  if (!login || !prompt) throw new HarnessError('not_found', 'That sign-in step is no longer waiting.')
414
565
  login.prompts.delete(promptId)
566
+ // The renderer's recoverable snapshot must stop presenting a prompt as soon as it is answered.
567
+ const current = this.latestLogins.get(login.accountId)
568
+ if (current?.loginId === loginId && current.prompt?.promptId === promptId) {
569
+ login.prompt = null
570
+ current.revision += 1
571
+ login.revision = current.revision
572
+ current.prompt = null
573
+ this.latestLogins.set(login.accountId, current)
574
+ this.bus.emit({ type: 'accounts.loginEvent', accountId: login.accountId, loginId, revision: current.revision, event: { type: 'promptClosed', promptId } })
575
+ }
415
576
  if (value === null) prompt.reject(new Error('Cancelled.'))
416
577
  else prompt.resolve(value)
417
578
  }
418
579
 
419
580
  loginCancel(accountId: string, loginId?: string | null): void {
420
- if (loginId) this.logins.get(loginId)?.cancel()
581
+ if (loginId) {
582
+ const login = this.logins.get(loginId)
583
+ if (login?.accountId === accountId) login.cancel()
584
+ } else for (const login of this.logins.values()) if (login.accountId === accountId) login.cancel()
421
585
  store.audit(this.dir, accountId, 'login.cancelled', '')
422
586
  }
423
587
 
@@ -428,26 +592,49 @@ export class Accounts {
428
592
  * account's stored credentials are written there (mode 0600) while the terminal runs, then
429
593
  * read back and removed.
430
594
  */
595
+ /** How many terminal sessions are running on this account right now. */
596
+ terminalCount(accountId: string): number {
597
+ return this.terminals.get(accountId) ?? 0
598
+ }
599
+
600
+ /**
601
+ * Starts a terminal's use of the account's auth.json. The file is written from the store when the
602
+ * first terminal of the account begins. While another process's terminal still uses it, the tokens
603
+ * already in the file stay, because that terminal may have refreshed them; only a provider the file
604
+ * lacks is added from the store, so a key saved after the other terminal began still reaches this one.
605
+ */
431
606
  async terminalBegin(account: Account): Promise<void> {
432
607
  const count = (this.terminals.get(account.id) ?? 0) + 1
433
608
  this.terminals.set(account.id, count)
434
609
  if (count > 1) return
435
610
  const path = join(env.configDir(this.dir, account), 'auth.json')
611
+ let shared = false
436
612
  try {
437
- const data: Record<string, unknown> = {}
438
- for (const provider of store.loadState(this.dir, account.id).piProviders) {
439
- const credential = await this.readCredential(account.id, provider).catch(() => null)
440
- if (credential) data[provider] = credential
613
+ shared = this.otherTerminalProcesses(account.id)
614
+ this.claimLease(account.id)
615
+ const existing = shared && existsSync(path) ? readAuth(path) : null
616
+ const data = await this.storedCredentials(account.id, existing ?? {})
617
+ if (!existing || Object.keys(data).length > Object.keys(existing).length) {
618
+ atomicWrite(path, JSON.stringify(data, null, 2))
619
+ chmodSync(path, 0o600)
441
620
  }
442
- atomicWrite(path, JSON.stringify(data, null, 2))
443
- chmodSync(path, 0o600)
621
+ this.terminalSnapshots.set(account.id, data)
444
622
  } catch (error) {
445
623
  this.terminals.delete(account.id)
446
- rmSync(path, { force: true })
624
+ this.terminalSnapshots.delete(account.id)
625
+ this.releaseLease(account.id)
626
+ if (!shared) rmSync(path, { force: true })
447
627
  throw error
448
628
  }
449
629
  }
450
630
 
631
+ /**
632
+ * Ends a terminal's use of the auth.json. Each terminal saves what its Pi changed since it began (a
633
+ * refreshed or new sign-in, or a provider it signed out of), even while another process still uses the
634
+ * file, so no refresh is lost when the other terminal ends first. A credential saved to the store after
635
+ * the terminal began is in neither the file nor the snapshot, so it stays. The last terminal of the
636
+ * account to end, in every process, removes the file.
637
+ */
451
638
  async terminalEnd(account: Account): Promise<void> {
452
639
  const remaining = Math.max(0, (this.terminals.get(account.id) ?? 1) - 1)
453
640
  if (remaining > 0) {
@@ -455,21 +642,72 @@ export class Accounts {
455
642
  return
456
643
  }
457
644
  this.terminals.delete(account.id)
645
+ const snapshot = this.terminalSnapshots.get(account.id) ?? {}
646
+ this.terminalSnapshots.delete(account.id)
647
+ this.releaseLease(account.id)
458
648
  const path = join(env.configDir(this.dir, account), 'auth.json')
459
- let data: Record<string, Credential> | null = null
649
+ const data = readAuth(path)
650
+ if (data) {
651
+ let changed = false
652
+ for (const provider of new Set([...Object.keys(snapshot), ...Object.keys(data)])) {
653
+ if (provider in data) {
654
+ if (sameCredential(data[provider], snapshot[provider])) continue
655
+ await this.writeCredential(account.id, provider, data[provider]).catch(() => undefined)
656
+ changed = true
657
+ } else if (provider in snapshot) {
658
+ await this.writeCredential(account.id, provider, null).catch(() => undefined)
659
+ changed = true
660
+ }
661
+ }
662
+ if (changed) {
663
+ this.invalidate(account.id)
664
+ store.audit(this.dir, account.id, 'credentials.synced', 'Pi terminal sign-in saved to the Keychain.')
665
+ }
666
+ }
667
+ if (!this.otherTerminalProcesses(account.id)) rmSync(path, { force: true })
668
+ }
669
+
670
+ /** The account's stored credentials, with `existing` kept as it is for every provider it already has. */
671
+ private async storedCredentials(accountId: string, existing: Record<string, Credential>): Promise<Record<string, Credential>> {
672
+ const data = { ...existing }
673
+ for (const provider of store.loadState(this.dir, accountId).piProviders) {
674
+ if (provider in data) continue
675
+ const credential = await this.readCredential(accountId, provider).catch(() => null)
676
+ if (credential) data[provider] = credential
677
+ }
678
+ return data
679
+ }
680
+
681
+ /**
682
+ * Whether another live process has a terminal on this account. Lease files of processes that have
683
+ * ended are removed, so a crash never leaves the account looking busy.
684
+ */
685
+ private otherTerminalProcesses(accountId: string): boolean {
686
+ const folder = leaseFolder(this.dir, accountId)
687
+ let names: string[]
460
688
  try {
461
- const parsed = readJson<Record<string, Credential>>(path)
462
- data = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
689
+ names = readdirSync(folder)
463
690
  } catch {
464
- data = null
691
+ return false
465
692
  }
466
- if (data) {
467
- for (const provider of store.loadState(this.dir, account.id).piProviders) if (!(provider in data)) await this.writeCredential(account.id, provider, null).catch(() => undefined)
468
- for (const [provider, credential] of Object.entries(data)) await this.writeCredential(account.id, provider, credential).catch(() => undefined)
469
- this.invalidate(account.id)
470
- store.audit(this.dir, account.id, 'credentials.synced', 'Pi terminal sign-in saved to the Keychain.')
693
+ let others = false
694
+ for (const name of names) {
695
+ const pid = Number(name)
696
+ if (!/^\d+$/.test(name) || pid <= 0 || pid === process.pid) continue
697
+ if (processAlive(pid)) others = true
698
+ else rmSync(join(folder, name), { force: true })
471
699
  }
472
- rmSync(path, { force: true })
700
+ return others
701
+ }
702
+
703
+ private claimLease(accountId: string): void {
704
+ const folder = leaseFolder(this.dir, accountId)
705
+ mkdirSync(folder, { recursive: true, mode: 0o700 })
706
+ atomicWrite(join(folder, String(process.pid)), JSON.stringify({ pid: process.pid, startedAt: now() }))
707
+ }
708
+
709
+ private releaseLease(accountId: string): void {
710
+ rmSync(join(leaseFolder(this.dir, accountId), String(process.pid)), { force: true })
473
711
  }
474
712
 
475
713
  // ---- Health from runs ---------------------------------------------------------------------
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { resolve } from 'node:path'
12
12
  import { log } from './core/bus'
13
13
  import { createHarness, type HarnessOptions } from './harness'
14
14
  import { runPluginsCli, terminalAsk } from './plugins/cli'
15
+ import { runHubCli } from './hub/cli'
15
16
  import { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
16
17
  import { RpcPeer, stdioTransport } from './server/rpc'
17
18
  import { attach } from './server/serve'
@@ -19,16 +20,17 @@ import { runAppsCli, runWikiCli } from './terminal/cli'
19
20
  import { runStandaloneTerminal, standaloneArguments } from './terminal/standalone'
20
21
 
21
22
  const USAGE = `Usage: xingular-harness [--data <dir>] [--state <dir>]
22
- xingular-harness <terminal | serve | wiki | mcp | plugins> [arguments]
23
+ xingular-harness <terminal | serve | wiki | mcp | plugins | hub> [arguments]
23
24
 
24
- (no command) Start the interactive Pi terminal
25
- terminal Start the interactive Pi terminal
25
+ (no command) Start the interactive Harness terminal
26
+ terminal Start the interactive Harness terminal
26
27
  serve --state <dir> [--data <dir>] [--name <product>] [--builtins <dir>]
27
28
  [--host-secrets] [--host-policy] [--host-tools] [--host-directory]
28
29
  Serve the harness over newline-delimited JSON
29
30
  wiki <command> Memory and Wiki commands for a terminal session
30
31
  mcp <command> Connected-app commands for a terminal session
31
32
  plugins <command> List, install, switch and trust plugins (plugins help)
33
+ hub <command> Browse and install curated items (hub help)
32
34
 
33
35
  Standalone terminal options:
34
36
  --data <dir> Data directory (default: ~/.xingular-harness/data)
@@ -91,6 +93,7 @@ function serveCommand(argv: string[]): Promise<number> {
91
93
  async function main(): Promise<number> {
92
94
  const argv = process.argv.slice(2)
93
95
  const [command, ...rest] = argv
96
+ if (command === 'terminal-frontend') return (await import('./terminal/tui/application')).runTerminalFrontend(rest)
94
97
  if (argv.includes('--help') || argv.includes('-h')) {
95
98
  process.stdout.write(USAGE)
96
99
  return 0
@@ -101,6 +104,7 @@ async function main(): Promise<number> {
101
104
  if (command === 'wiki') return runWikiCli(rest)
102
105
  if (command === 'mcp') return runAppsCli(rest)
103
106
  if (command === 'plugins') return runPluginsCli(rest, { ask: terminalAsk() })
107
+ if (command === 'hub') return runHubCli(rest)
104
108
  process.stdout.write(USAGE)
105
109
  return command ? 2 : 0
106
110
  }
@@ -0,0 +1,22 @@
1
+ import { afterEach, expect, test, vi } from 'vitest'
2
+
3
+ import { log, logLine, setLogSink } from './bus'
4
+
5
+ afterEach(() => setLogSink(null))
6
+
7
+ test('diagnostics go to the sink when one is set, and to stderr otherwise', () => {
8
+ const lines: string[] = []
9
+ setLogSink((text) => void lines.push(text))
10
+ log('maintenance', 'paused')
11
+ logLine('[index] reindex failed: boom')
12
+ expect(lines).toEqual(['[harness] maintenance paused', '[index] reindex failed: boom'])
13
+
14
+ setLogSink(null)
15
+ const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
16
+ try {
17
+ logLine('plain')
18
+ expect(write).toHaveBeenCalledWith('plain\n')
19
+ } finally {
20
+ write.mockRestore()
21
+ }
22
+ })
package/src/core/bus.ts CHANGED
@@ -28,7 +28,22 @@ export class Bus {
28
28
  }
29
29
  }
30
30
 
31
- /** Diagnostics go to stderr; stdout may be carrying a protocol. */
31
+ let logSink: ((text: string) => void) | null = null
32
+
33
+ /**
34
+ * Sends diagnostics somewhere other than stderr, or back to stderr with `null`. A host whose stderr is
35
+ * the terminal a frontend is drawing on must not write into it, so it sets a sink.
36
+ */
37
+ export function setLogSink(sink: ((text: string) => void) | null): void {
38
+ logSink = sink
39
+ }
40
+
41
+ /** One line of diagnostics. Diagnostics go to stderr by default; stdout may be carrying a protocol. */
42
+ export function logLine(text: string): void {
43
+ if (logSink) logSink(text)
44
+ else process.stderr.write(`${text}\n`)
45
+ }
46
+
32
47
  export function log(...parts: unknown[]): void {
33
- process.stderr.write(`[harness] ${parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' ')}\n`)
48
+ logLine(`[harness] ${parts.map((part) => (typeof part === 'string' ? part : JSON.stringify(part))).join(' ')}`)
34
49
  }
package/src/harness.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // The harness, assembled: Pi sessions on isolated accounts, a compiled prompt, a memory
2
2
  // lifecycle, tools, connected apps, and skills. An application embeds one of these and adds
3
3
  // its own prompt sections, tools, and policy; nothing here knows what that application is.
4
+ import { Subagents } from './sessions/subagentService'
4
5
  import { mkdirSync } from 'node:fs'
5
6
  import { join } from 'node:path'
6
7
 
7
8
  import { Accounts } from './accounts/accounts'
9
+ import { Hub, type HubOptions } from './hub/hub'
8
10
  import * as capabilities from './capabilities/api'
9
11
  import { ensureSharedDir, isolateProcess } from './accounts/env'
10
12
  import { fileSecretStore, type SecretStore } from './accounts/secrets'
@@ -15,6 +17,7 @@ import { readJsonOr } from './core/fs'
15
17
  import { closeIndex } from './core/index'
16
18
  import { projectsOrNone } from './core/projects'
17
19
  import { ConnectedApps } from './mcp/service'
20
+ import type { AppOAuthClients } from './mcp/types'
18
21
  import { migrateConnectionScopes } from './mcp/scopeMigration'
19
22
  import type { MaintainerFactory } from './memory/maintainer'
20
23
  import { Maintenance } from './memory/maintenance'
@@ -41,6 +44,7 @@ import * as registry from './skills/registry'
41
44
  import { Terminals, type TerminalAssets } from './terminal/terminals'
42
45
 
43
46
  export interface HarnessOptions {
47
+ hub?: HubOptions
44
48
  /** Where knowledge and history live. May be set later with `open`. */
45
49
  dataRoot?: string | null
46
50
  /** Device-local state that never belongs in the data root: accounts, approvals, connections, sockets. */
@@ -54,11 +58,16 @@ export interface HarnessOptions {
54
58
  /** Replaces Pi, for tests. */
55
59
  sessionFactory?: SessionFactory
56
60
  maintainerFactory?: MaintainerFactory
61
+ /** Delegate maintenance execution to the embedding host through durable requests. */
62
+ externalMaintenance?: boolean
63
+ maintenanceExcludedTriggers?: string[]
64
+ /** Queue consolidation after every settled run (the default). False leaves memory to explicit proposals and scheduled merges. */
65
+ maintenanceRunTrigger?: boolean
57
66
  /** How long the maintenance queue stays empty before full memory blocks are condensed. */
58
67
  idleMs?: number
59
68
  /** Where the built commands a terminal session runs are, when they are not beside this package. */
60
69
  assets?: TerminalAssets
61
- /** The `pi` executable a terminal session runs. Defaults to the one on the search path. */
70
+ /** Optional legacy executable override. The default is the Harness-owned terminal frontend. */
62
71
  piProgram?: () => string | null
63
72
  /** Folders of built-in skills, prompts, and rules, each laid out like `registry/`. */
64
73
  builtins?: string[]
@@ -69,6 +78,11 @@ export interface HarnessOptions {
69
78
  plugins?: PluginDescriptor[]
70
79
  /** Replaces Pi's own search for extensions, skills, prompts and themes, for tests. */
71
80
  resolver?: Resolver
81
+ /**
82
+ * The application's own registered OAuth clients, so a connector it publishes needs no client
83
+ * from the person. Only a catalog entry listed in `ids` can use one, and nothing is stored.
84
+ */
85
+ oauthClients?: AppOAuthClients
72
86
  /** Replaces how a plugin's hooks file is read as a module, for tests. */
73
87
  hookImporter?: (address: string) => Promise<unknown>
74
88
  }
@@ -102,6 +116,7 @@ class Env implements HarnessEnv {
102
116
  }
103
117
 
104
118
  export class Harness {
119
+ readonly hub: Hub
105
120
  readonly bus = new Bus()
106
121
  readonly env: Env
107
122
  readonly stateDir: string
@@ -116,6 +131,7 @@ export class Harness {
116
131
  /** Every part described as a plugin: the harness's own, then the application's. */
117
132
  descriptors: readonly PluginDescriptor[] = HARNESS_PLUGINS
118
133
  applicationPlugins: ReadonlySet<string> = new Set()
134
+ readonly subagents: Subagents
119
135
  readonly sessions: Sessions
120
136
  readonly maintenance: Maintenance
121
137
  readonly terminals: Terminals
@@ -129,6 +145,7 @@ export class Harness {
129
145
  // Before anything can start a session: no inherited credential, no `~/.pi`.
130
146
  isolateProcess(join(this.stateDir, 'accounts'))
131
147
  this.env = new Env(options.dataRoot || null, options.directory ?? EMPTY_DIRECTORY, options.productName ?? DEFAULT_PRODUCT_NAME, this.bus)
148
+ this.hub = new Hub(this, options.hub)
132
149
  this.policy = options.policy ?? {}
133
150
  registry.configureBuiltins(options.builtins ?? [])
134
151
  const secrets = options.secrets ?? fileSecretStore(join(this.stateDir, 'secrets.json'))
@@ -143,7 +160,7 @@ export class Harness {
143
160
  redact: (event) => this.apps.connections.redactEvent(event),
144
161
  ended: (runId, sessionId) => this.apps.revokeRun(runId, sessionId)
145
162
  })
146
- this.apps = new ConnectedApps({ env: this.env, stateDir: this.stateDir, secrets, bus: this.bus, runs: this.runs, policy: this.policy })
163
+ this.apps = new ConnectedApps({ env: this.env, stateDir: this.stateDir, secrets, bus: this.bus, runs: this.runs, policy: this.policy, oauthClients: options.oauthClients })
147
164
  this.plugins = new Plugins({ stateDir: this.stateDir, bus: this.bus, resolver: options.resolver, attachments: () => (this.env.dataRoot ? attachments.load(this.env.dataRoot) : null), packagesDir: () => (this.env.dataRoot ? join(this.env.dataRoot, 'plugins') : null) })
148
165
  this.hooks = new Hooks({
149
166
  stateDir: this.stateDir,
@@ -173,7 +190,9 @@ export class Harness {
173
190
  reloadTimer.unref?.()
174
191
  })
175
192
  this.describe(options.plugins ?? [])
193
+ this.subagents = new Subagents(this.env, this.runs, (input) => this.sessions.run(input))
176
194
  this.sessions = new Sessions({
195
+ subagents: this.subagents,
177
196
  env: this.env,
178
197
  accounts: this.accounts,
179
198
  runs: this.runs,
@@ -183,8 +202,8 @@ export class Harness {
183
202
  resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }),
184
203
  factory: options.sessionFactory
185
204
  })
186
- this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs })
187
- this.terminals = new Terminals({ env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram })
205
+ this.maintenance = new Maintenance({ env: this.env, bus: this.bus, accounts: this.accounts, factory: options.maintainerFactory, idleMs: options.idleMs, external: options.externalMaintenance, excludedTriggers: options.maintenanceExcludedTriggers, runTrigger: options.maintenanceRunTrigger })
206
+ this.terminals = new Terminals({ subagents: this.subagents, resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }), env: this.env, stateDir: this.stateDir, accounts: this.accounts, runs: this.runs, approvals: this.approvals, apps: this.apps, policy: this.policy, bus: this.bus, plugins: this.plugins, pluginControl: { list: (scope) => pluginApi.list(this, scope), attach: (params, on) => pluginApi.attach(this, params, on), trust: (scope) => pluginApi.trust(this, scope), untrust: (scope) => pluginApi.untrust(this, scope) }, assets: options.assets, program: options.piProgram, hub: this.hub })
188
207
  this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)))
189
208
  this.runs.listen((event, run) => {
190
209
  this.accounts.onRunEvent(event, run)
@@ -193,6 +212,7 @@ export class Harness {
193
212
  if ((event.type === 'completed' || event.type === 'failed') && run) {
194
213
  this.hooks.emit({ type: 'history.run.ended', runId: run.id, sessionId: run.sessionId, status: run.status, goal: run.goal ?? null, agentId: run.agentId ?? null, projectId: run.projectId ?? null, startedAt: run.startedAt, endedAt: run.endedAt ?? null, at: event.timestamp })
195
214
  }
215
+ if (event.type === 'completed' || event.type === 'failed' || (event.type === 'status.changed' && (event.payload as { status?: string }).status === 'interrupted')) this.maintenance.finishRun(event.runId, event.type !== 'completed')
196
216
  this.maintenance.onRunEvent(event)
197
217
  })
198
218
  }
@@ -215,8 +235,10 @@ export class Harness {
215
235
  * older Wiki is migrated.
216
236
  */
217
237
  open(dataRoot: string, agentIds: readonly string[] = []): Recovery {
238
+ if (this.hub.busy && this.env.dataRoot !== dataRoot) throw new HarnessError('hub_busy', 'Wait for the hub installation before changing the data location.')
218
239
  if (this.env.dataRoot && this.env.dataRoot !== dataRoot) closeIndex(this.env.dataRoot)
219
240
  this.env.dataRoot = dataRoot
241
+ this.hub.watch(dataRoot)
220
242
  const root = dataRoot
221
243
  this.hooks.watch(store.wikiDir(root))
222
244
  void this.hooks.reload()
@@ -281,6 +303,7 @@ export class Harness {
281
303
 
282
304
  /** Stops live runs and releases the index. */
283
305
  async close(): Promise<void> {
306
+ this.hub.close()
284
307
  this.maintenance.stop()
285
308
  await this.terminals.close()
286
309
  await this.runs.interruptAll()