@xingular/harness 0.1.5 → 0.1.7

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 (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. package/src/tools/spawnSubagent.ts +92 -0
@@ -45,8 +45,26 @@ function validProvider(provider: string): string {
45
45
 
46
46
  interface PendingLogin {
47
47
  accountId: string
48
+ provider: string
49
+ method: 'oauth' | 'api_key'
48
50
  cancel: () => void
49
51
  prompts: Map<string, { resolve: (value: string) => void; reject: (error: Error) => void }>
52
+ revision: number
53
+ events: Record<string, unknown>[]
54
+ prompt: Record<string, unknown> | null
55
+ }
56
+
57
+ export interface AccountLoginSnapshot {
58
+ accountId: string
59
+ loginId: string
60
+ provider: string
61
+ method: string
62
+ revision: number
63
+ status: 'running' | 'completed' | 'failed' | 'cancelled'
64
+ events: Record<string, unknown>[]
65
+ prompt: Record<string, unknown> | null
66
+ success: boolean | null
67
+ error: string | null
50
68
  }
51
69
 
52
70
  interface Probe {
@@ -71,7 +89,9 @@ export class Accounts {
71
89
  private readonly bus: Bus
72
90
  private readonly runtimes = new Map<string, Promise<ModelRuntime>>()
73
91
  private readonly logins = new Map<string, PendingLogin>()
92
+ private readonly latestLogins = new Map<string, AccountLoginSnapshot>()
74
93
  private readonly terminals = new Map<string, number>()
94
+ private installationDeviceIdPromise: Promise<string> | null = null
75
95
 
76
96
  constructor(options: AccountsOptions) {
77
97
  this.dir = options.dir
@@ -157,6 +177,25 @@ export class Accounts {
157
177
  }
158
178
  }
159
179
 
180
+ /** Pi's OAuth device ID belongs to this Harness installation, not to one isolated account. */
181
+ installationDeviceId(): Promise<string> {
182
+ if (!this.installationDeviceIdPromise) {
183
+ this.installationDeviceIdPromise = this.loadInstallationDeviceId().catch((error: unknown) => {
184
+ this.installationDeviceIdPromise = null
185
+ throw error
186
+ })
187
+ }
188
+ return this.installationDeviceIdPromise
189
+ }
190
+
191
+ private async loadInstallationDeviceId(): Promise<string> {
192
+ const { SettingsManager } = await import('@earendil-works/pi-coding-agent')
193
+ const settings = SettingsManager.create(process.cwd(), env.ensureSharedDir(this.dir))
194
+ const deviceId = settings.getOrCreateDeviceId()
195
+ await settings.flush()
196
+ return deviceId
197
+ }
198
+
160
199
  // ---- The Pi runtime ---------------------------------------------------------------------
161
200
 
162
201
  /** The lazily loaded Pi runtime of one account: its models and its credentials. Pi's own `~/.pi` is never used. */
@@ -250,7 +289,7 @@ export class Accounts {
250
289
  const keepLimitError = current.lastError !== null && store.isLimitCategory(current.lastError.category) && store.limitErrorActive(current.lastError)
251
290
  if (probe.signedIn === true && !keepLimitError) current.lastError = null
252
291
  } else {
253
- current.lastError = { category: 'unknown', message: store.scrub(failure ?? ''), at: now(), resetsAt: null }
292
+ current.lastError = { category: classifyMessage(failure ?? '') ?? 'unknown', message: store.scrub(failure ?? ''), at: now(), resetsAt: null }
254
293
  }
255
294
  })
256
295
  if (previous !== state.health) store.audit(this.dir, account.id, 'health.changed', `${previous} → ${state.health} (status check)`)
@@ -295,6 +334,8 @@ export class Accounts {
295
334
  /** Deletes the account: removes its stored credentials and its runtime folder. Sessions pinned to it ask for another account next time they start. */
296
335
  async remove(accountId: string): Promise<AccountsOverview> {
297
336
  const account = this.find(accountId)
337
+ for (const login of this.logins.values()) if (login.accountId === accountId) login.cancel()
338
+ this.latestLogins.delete(accountId)
298
339
  await this.forgetCredentials(account).catch(() => undefined)
299
340
  await this.secrets.delete(secretKey(account.id, 'apiKey')).catch(() => undefined)
300
341
  store.forgetAccount(this.dir, account)
@@ -354,7 +395,7 @@ export class Accounts {
354
395
  return this.overview()
355
396
  }
356
397
 
357
- private finishLogin(loginId: string, success: boolean, error: string | null): void {
398
+ private finishLogin(loginId: string, success: boolean, error: string | null, cancelled = false): void {
358
399
  const login = this.logins.get(loginId)
359
400
  if (!login) return
360
401
  this.logins.delete(loginId)
@@ -363,7 +404,25 @@ export class Accounts {
363
404
  store.audit(this.dir, login.accountId, success ? 'login.completed' : 'login.failed', error ?? '')
364
405
  // A fresh sign-in starts clean; a limit that still applies is reported again by the next run.
365
406
  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 })
407
+ login.revision += 1
408
+ const status: AccountLoginSnapshot['status'] = success ? 'completed' : cancelled ? 'cancelled' : 'failed'
409
+ if (this.latestLogins.get(login.accountId)?.loginId === loginId) {
410
+ this.latestLogins.set(login.accountId, {
411
+ accountId: login.accountId,
412
+ loginId,
413
+ provider: login.provider,
414
+ method: login.method,
415
+ revision: login.revision,
416
+ status,
417
+ // A terminal result is enough to reconcile the client. In particular, do not retain a
418
+ // device code after the attempt ends.
419
+ events: [],
420
+ prompt: null,
421
+ success,
422
+ error
423
+ })
424
+ }
425
+ this.bus.emit({ type: 'accounts.login', accountId: login.accountId, loginId, revision: login.revision, success, error })
367
426
  try {
368
427
  void this.refreshOne(this.find(login.accountId)).catch(() => undefined)
369
428
  } catch {
@@ -379,12 +438,55 @@ export class Accounts {
379
438
  const account = this.find(accountId)
380
439
  if (!provider) throw invalid('Choose which Pi provider to sign in to.')
381
440
  store.audit(this.dir, account.id, 'login.started', `${method ?? 'oauth'} · ${provider}`)
382
- const runtime = await this.runtime(account)
383
441
  const loginId = randomUUID()
384
442
  const controller = new AbortController()
385
- const login: PendingLogin = { accountId: account.id, prompts: new Map(), cancel: () => controller.abort() }
443
+ const login: PendingLogin = {
444
+ accountId: account.id,
445
+ provider,
446
+ method: method === 'api_key' ? 'api_key' : 'oauth',
447
+ prompts: new Map(),
448
+ cancel: () => controller.abort(),
449
+ revision: 0,
450
+ events: [],
451
+ prompt: null
452
+ }
386
453
  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 })
454
+ this.latestLogins.set(account.id, {
455
+ accountId: account.id,
456
+ loginId,
457
+ provider: login.provider,
458
+ method: login.method,
459
+ revision: 0,
460
+ status: 'running',
461
+ events: [],
462
+ prompt: null,
463
+ success: null,
464
+ error: null
465
+ })
466
+ const event = (payload: Record<string, unknown>): void => {
467
+ login.revision += 1
468
+ if (payload.type === 'prompt') login.prompt = payload
469
+ else if (payload.type === 'promptClosed' && login.prompt?.promptId === payload.promptId) login.prompt = null
470
+ else {
471
+ login.events.push(payload)
472
+ if (login.events.length > 50) login.events.shift()
473
+ }
474
+ if (this.latestLogins.get(account.id)?.loginId === loginId) {
475
+ this.latestLogins.set(account.id, {
476
+ accountId: account.id,
477
+ loginId,
478
+ provider: login.provider,
479
+ method: login.method,
480
+ revision: login.revision,
481
+ status: 'running',
482
+ events: login.events.map((step) => ({ ...step })),
483
+ prompt: login.prompt ? { ...login.prompt } : null,
484
+ success: null,
485
+ error: null
486
+ })
487
+ }
488
+ this.bus.emit({ type: 'accounts.loginEvent', accountId: account.id, loginId, revision: login.revision, event: payload })
489
+ }
388
490
  const interaction = {
389
491
  signal: controller.signal,
390
492
  notify: (notification: Record<string, unknown>) => event(notification),
@@ -400,24 +502,53 @@ export class Accounts {
400
502
  event({ type: 'prompt', promptId, prompt: { type: prompt.type, message: prompt.message, placeholder: prompt.placeholder ?? null, options: prompt.options ?? null } })
401
503
  })
402
504
  }
403
- runtime
404
- .login(provider, method === 'api_key' ? 'api_key' : 'oauth', interaction as never)
505
+ void this.runtime(account)
506
+ .then(async (runtime) => {
507
+ if (login.method !== 'oauth') return runtime.login(provider, login.method, interaction as never)
508
+ const deviceId = await this.installationDeviceId()
509
+ return runtime.login(provider, login.method, interaction as never, { getDeviceId: () => deviceId })
510
+ })
405
511
  .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)))
512
+ .catch((error: unknown) => this.finishLogin(
513
+ loginId,
514
+ false,
515
+ controller.signal.aborted ? 'Sign-in was cancelled.' : error instanceof Error ? error.message : String(error),
516
+ controller.signal.aborted
517
+ ))
407
518
  return { loginId }
408
519
  }
409
520
 
521
+ /** Current in-memory sign-in progress for one account; codes and prompts are never written to disk. */
522
+ loginStatus(accountId: string): AccountLoginSnapshot | null {
523
+ this.find(accountId)
524
+ const snapshot = this.latestLogins.get(accountId)
525
+ return snapshot ? structuredClone(snapshot) : null
526
+ }
527
+
410
528
  loginRespond(loginId: string, promptId: string, value: string | null): void {
411
529
  const login = this.logins.get(loginId)
412
530
  const prompt = login?.prompts.get(promptId)
413
531
  if (!login || !prompt) throw new HarnessError('not_found', 'That sign-in step is no longer waiting.')
414
532
  login.prompts.delete(promptId)
533
+ // The renderer's recoverable snapshot must stop presenting a prompt as soon as it is answered.
534
+ const current = this.latestLogins.get(login.accountId)
535
+ if (current?.loginId === loginId && current.prompt?.promptId === promptId) {
536
+ login.prompt = null
537
+ current.revision += 1
538
+ login.revision = current.revision
539
+ current.prompt = null
540
+ this.latestLogins.set(login.accountId, current)
541
+ this.bus.emit({ type: 'accounts.loginEvent', accountId: login.accountId, loginId, revision: current.revision, event: { type: 'promptClosed', promptId } })
542
+ }
415
543
  if (value === null) prompt.reject(new Error('Cancelled.'))
416
544
  else prompt.resolve(value)
417
545
  }
418
546
 
419
547
  loginCancel(accountId: string, loginId?: string | null): void {
420
- if (loginId) this.logins.get(loginId)?.cancel()
548
+ if (loginId) {
549
+ const login = this.logins.get(loginId)
550
+ if (login?.accountId === accountId) login.cancel()
551
+ } else for (const login of this.logins.values()) if (login.accountId === accountId) login.cancel()
421
552
  store.audit(this.dir, accountId, 'login.cancelled', '')
422
553
  }
423
554
 
@@ -428,6 +559,11 @@ export class Accounts {
428
559
  * account's stored credentials are written there (mode 0600) while the terminal runs, then
429
560
  * read back and removed.
430
561
  */
562
+ /** How many terminal sessions are running on this account right now. */
563
+ terminalCount(accountId: string): number {
564
+ return this.terminals.get(accountId) ?? 0
565
+ }
566
+
431
567
  async terminalBegin(account: Account): Promise<void> {
432
568
  const count = (this.terminals.get(account.id) ?? 0) + 1
433
569
  this.terminals.set(account.id, count)
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
  }
package/src/harness.ts CHANGED
@@ -5,6 +5,7 @@ import { mkdirSync } from 'node:fs'
5
5
  import { join } from 'node:path'
6
6
 
7
7
  import { Accounts } from './accounts/accounts'
8
+ import { Hub, type HubOptions } from './hub/hub'
8
9
  import * as capabilities from './capabilities/api'
9
10
  import { ensureSharedDir, isolateProcess } from './accounts/env'
10
11
  import { fileSecretStore, type SecretStore } from './accounts/secrets'
@@ -41,6 +42,7 @@ import * as registry from './skills/registry'
41
42
  import { Terminals, type TerminalAssets } from './terminal/terminals'
42
43
 
43
44
  export interface HarnessOptions {
45
+ hub?: HubOptions
44
46
  /** Where knowledge and history live. May be set later with `open`. */
45
47
  dataRoot?: string | null
46
48
  /** Device-local state that never belongs in the data root: accounts, approvals, connections, sockets. */
@@ -54,11 +56,16 @@ export interface HarnessOptions {
54
56
  /** Replaces Pi, for tests. */
55
57
  sessionFactory?: SessionFactory
56
58
  maintainerFactory?: MaintainerFactory
59
+ /** Delegate maintenance execution to the embedding host through durable requests. */
60
+ externalMaintenance?: boolean
61
+ maintenanceExcludedTriggers?: string[]
62
+ /** Queue consolidation after every settled run (the default). False leaves memory to explicit proposals and scheduled merges. */
63
+ maintenanceRunTrigger?: boolean
57
64
  /** How long the maintenance queue stays empty before full memory blocks are condensed. */
58
65
  idleMs?: number
59
66
  /** Where the built commands a terminal session runs are, when they are not beside this package. */
60
67
  assets?: TerminalAssets
61
- /** The `pi` executable a terminal session runs. Defaults to the one on the search path. */
68
+ /** Optional legacy executable override. The default is the Harness-owned terminal frontend. */
62
69
  piProgram?: () => string | null
63
70
  /** Folders of built-in skills, prompts, and rules, each laid out like `registry/`. */
64
71
  builtins?: string[]
@@ -102,6 +109,7 @@ class Env implements HarnessEnv {
102
109
  }
103
110
 
104
111
  export class Harness {
112
+ readonly hub: Hub
105
113
  readonly bus = new Bus()
106
114
  readonly env: Env
107
115
  readonly stateDir: string
@@ -129,6 +137,7 @@ export class Harness {
129
137
  // Before anything can start a session: no inherited credential, no `~/.pi`.
130
138
  isolateProcess(join(this.stateDir, 'accounts'))
131
139
  this.env = new Env(options.dataRoot || null, options.directory ?? EMPTY_DIRECTORY, options.productName ?? DEFAULT_PRODUCT_NAME, this.bus)
140
+ this.hub = new Hub(this, options.hub)
132
141
  this.policy = options.policy ?? {}
133
142
  registry.configureBuiltins(options.builtins ?? [])
134
143
  const secrets = options.secrets ?? fileSecretStore(join(this.stateDir, 'secrets.json'))
@@ -183,8 +192,8 @@ export class Harness {
183
192
  resolveCapabilities: (caller, tools, cwd) => capabilities.resolve(this, { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null, cwd, tools }),
184
193
  factory: options.sessionFactory
185
194
  })
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 })
195
+ 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 })
196
+ 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, hub: this.hub })
188
197
  this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)))
189
198
  this.runs.listen((event, run) => {
190
199
  this.accounts.onRunEvent(event, run)
@@ -193,6 +202,7 @@ export class Harness {
193
202
  if ((event.type === 'completed' || event.type === 'failed') && run) {
194
203
  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
204
  }
205
+ 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
206
  this.maintenance.onRunEvent(event)
197
207
  })
198
208
  }
@@ -215,8 +225,10 @@ export class Harness {
215
225
  * older Wiki is migrated.
216
226
  */
217
227
  open(dataRoot: string, agentIds: readonly string[] = []): Recovery {
228
+ if (this.hub.busy && this.env.dataRoot !== dataRoot) throw new HarnessError('hub_busy', 'Wait for the hub installation before changing the data location.')
218
229
  if (this.env.dataRoot && this.env.dataRoot !== dataRoot) closeIndex(this.env.dataRoot)
219
230
  this.env.dataRoot = dataRoot
231
+ this.hub.watch(dataRoot)
220
232
  const root = dataRoot
221
233
  this.hooks.watch(store.wikiDir(root))
222
234
  void this.hooks.reload()
@@ -281,6 +293,7 @@ export class Harness {
281
293
 
282
294
  /** Stops live runs and releases the index. */
283
295
  async close(): Promise<void> {
296
+ this.hub.close()
284
297
  this.maintenance.stop()
285
298
  await this.terminals.close()
286
299
  await this.runs.interruptAll()
@@ -0,0 +1,123 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "entries": [
4
+ {
5
+ "id": "xingular/evidence-first",
6
+ "title": "Evidence first",
7
+ "description": "Keep observations, inferences, and unknowns distinct.",
8
+ "publisher": "Xingular",
9
+ "license": "MIT",
10
+ "version": "1.0.0",
11
+ "category": "rule",
12
+ "channel": "official",
13
+ "tags": [
14
+ "rule"
15
+ ],
16
+ "requires": {
17
+ "harness": "0.1.6"
18
+ },
19
+ "sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/evidence-first/1.0.0",
20
+ "delivery": {
21
+ "kind": "registry",
22
+ "registryKind": "rules",
23
+ "url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/evidence-first/1.0.0/payload.json",
24
+ "sha256": "0f77661ea41e27bcb51aad982d15d493b893c5c1ba1eb6046a21315ddac07c5b",
25
+ "bundled": "{\n \"items\": [\n {\n \"name\": \"hub-evidence-first\",\n \"files\": [\n {\n \"path\": \"CONTENT.md\",\n \"encoding\": \"utf8\",\n \"content\": \"---\\nname: Evidence first\\ndescription: Distinguish observations, inferences, and unknowns.\\n---\\n\\nSupport factual claims with inspectable evidence. Label inferences and uncertainties. Correct a claim when better evidence changes it.\\n\"\n }\n ]\n }\n ]\n}\n"
26
+ }
27
+ },
28
+ {
29
+ "id": "xingular/github-connector",
30
+ "title": "GitHub connector",
31
+ "description": "A setup recipe for the GitHub MCP server; provide your own credentials.",
32
+ "publisher": "Xingular",
33
+ "license": "MIT",
34
+ "version": "1.0.0",
35
+ "category": "connector",
36
+ "channel": "official",
37
+ "tags": [
38
+ "connector"
39
+ ],
40
+ "requires": {
41
+ "harness": "0.1.6"
42
+ },
43
+ "sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/github-connector/1.0.0",
44
+ "delivery": {
45
+ "kind": "document",
46
+ "url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/github-connector/1.0.0/payload.json",
47
+ "sha256": "ec5bd9675e2977b9d880cf81732d84ac72b04af271048499415eaa6288896223",
48
+ "bundled": "{\n \"id\": \"github-hub\",\n \"name\": \"GitHub\",\n \"description\": \"Repositories, issues, and pull requests\",\n \"kind\": \"remote\",\n \"docsUrl\": \"https://github.com/github/github-mcp-server\",\n \"url\": \"https://api.githubcopilot.com/mcp/\",\n \"auth\": \"headers\",\n \"oauth\": null,\n \"fields\": [\n {\n \"header\": \"Authorization\",\n \"label\": \"Personal access token\",\n \"prefix\": \"Bearer \",\n \"help\": \"Provide a token for only the repositories and permissions you need.\"\n }\n ],\n \"setup\": [\n \"Create a personal access token in GitHub with the access you want to grant.\",\n \"Review the connection before enabling it.\"\n ],\n \"notice\": null,\n \"provenance\": null,\n \"suggestedReadTools\": []\n}\n"
49
+ }
50
+ },
51
+ {
52
+ "id": "xingular/research-starter",
53
+ "title": "Research starter",
54
+ "description": "A starting agent for comparing sources and writing supported answers.",
55
+ "publisher": "Xingular",
56
+ "license": "MIT",
57
+ "version": "1.0.0",
58
+ "category": "agent-template",
59
+ "channel": "official",
60
+ "tags": [
61
+ "agent-template"
62
+ ],
63
+ "requires": {
64
+ "harness": "0.1.6"
65
+ },
66
+ "sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/research-starter/1.0.0",
67
+ "delivery": {
68
+ "kind": "document",
69
+ "url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/research-starter/1.0.0/payload.json",
70
+ "sha256": "a2f672583df6f4049cc153ba1e173192301220e5f8817f9ad5a4fe87af25f52b",
71
+ "bundled": "{\n \"schemaVersion\": 1,\n \"id\": \"research-starter\",\n \"title\": \"Research starter\",\n \"description\": \"Compare sources and explain the evidence behind an answer.\",\n \"version\": \"1.0.0\",\n \"author\": \"Xingular\",\n \"license\": \"MIT\",\n \"agent\": {\n \"name\": \"Research starter\",\n \"icon\": {\n \"kind\": \"mascot\",\n \"shape\": \"prism\",\n \"color\": \"violet\",\n \"material\": \"glass\"\n },\n \"description\": \"Investigates questions with sources.\",\n \"instructions\": \"Help the user investigate a question. Start with reliable sources, explain what the evidence supports, cite the sources, and state unresolved questions. Ask the user when their goal is unclear.\",\n \"model\": null,\n \"effort\": \"high\",\n \"tools\": [\n \"read\",\n \"grep\",\n \"find\",\n \"ls\",\n \"web_search\",\n \"web_fetch\"\n ]\n },\n \"skills\": [],\n \"connections\": []\n}\n"
72
+ }
73
+ },
74
+ {
75
+ "id": "xingular/source-review",
76
+ "title": "Review a source",
77
+ "description": "Check who wrote a source, what it supports, and what remains uncertain.",
78
+ "publisher": "Xingular",
79
+ "license": "MIT",
80
+ "version": "1.0.0",
81
+ "category": "skill",
82
+ "channel": "official",
83
+ "tags": [
84
+ "skill"
85
+ ],
86
+ "requires": {
87
+ "harness": "0.1.6"
88
+ },
89
+ "sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/source-review/1.0.0",
90
+ "delivery": {
91
+ "kind": "registry",
92
+ "registryKind": "skills",
93
+ "url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/source-review/1.0.0/payload.json",
94
+ "sha256": "bb0617889ee1ca39611a10952bec3086fa3986e9ce10a809c473c57acf47bc07",
95
+ "bundled": "{\n \"items\": [\n {\n \"name\": \"hub-source-review\",\n \"files\": [\n {\n \"path\": \"SKILL.md\",\n \"encoding\": \"utf8\",\n \"content\": \"---\\nname: hub-source-review\\ndescription: Review the evidence behind a source before using its claims.\\n---\\n\\n# Review a source\\n\\nRecord its author and date. Identify the original evidence. Write what the source supports and which uncertainties remain. Cite the source so the reader can inspect it.\\n\"\n }\n ]\n }\n ]\n}\n"
96
+ }
97
+ },
98
+ {
99
+ "id": "xingular/summarize-source",
100
+ "title": "Summarize a source",
101
+ "description": "Summarize a provided source and explain its limitations.",
102
+ "publisher": "Xingular",
103
+ "license": "MIT",
104
+ "version": "1.0.0",
105
+ "category": "prompt",
106
+ "channel": "official",
107
+ "tags": [
108
+ "prompt"
109
+ ],
110
+ "requires": {
111
+ "harness": "0.1.6"
112
+ },
113
+ "sourceUrl": "https://github.com/tanntnny/xingular-hub/tree/main/entries/xingular/summarize-source/1.0.0",
114
+ "delivery": {
115
+ "kind": "registry",
116
+ "registryKind": "prompts",
117
+ "url": "https://raw.githubusercontent.com/tanntnny/xingular-hub/main/entries/xingular/summarize-source/1.0.0/payload.json",
118
+ "sha256": "12a646d474fe3cd822970869c972d1d9400c7f6350388dc735dce5c31a22680a",
119
+ "bundled": "{\n \"items\": [\n {\n \"name\": \"hub-summarize-source\",\n \"files\": [\n {\n \"path\": \"CONTENT.md\",\n \"encoding\": \"utf8\",\n \"content\": \"---\\nname: summarize-source\\ndescription: Summarize the supplied source and its limits.\\n---\\n\\nSummarize the source below. Include its main claims, supporting evidence, limitations, and questions it leaves unanswered.\\n\\n$@\\n\"\n }\n ]\n }\n ]\n}\n"
120
+ }
121
+ }
122
+ ]
123
+ }
package/src/hub/cli.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { homedir } from 'node:os'
2
+ import { join, resolve } from 'node:path'
3
+ import { Harness, type HarnessOptions } from '../harness'
4
+
5
+ export const HUB_USAGE = `Usage: xingular-harness hub <list | show | install> [id@version] [options]
6
+
7
+ list [--refresh] Browse the curated catalog and locally installed versions
8
+ show <id@version> Show publisher, license, source, and delivery details
9
+ install <id@version> Install the exact reviewed version, disabled and untrusted
10
+
11
+ --data <dir> Target data library (default: ~/.xingular-harness/data)
12
+ --state <dir> Target device state (default: ~/.xingular-harness/state)
13
+ --refresh Refresh the public catalog; cached items remain usable offline
14
+ `
15
+
16
+ export async function runHubCli(argv: string[], dependencies: { home?: string; write?: (text: string) => void; createHarness?: (options: HarnessOptions) => Harness } = {}): Promise<number> {
17
+ const write = dependencies.write ?? ((text: string) => void process.stdout.write(text))
18
+ const args: string[] = []
19
+ const values: Record<string, string> = {}
20
+ let refresh = false
21
+ for (let index = 0; index < argv.length; index += 1) {
22
+ const arg = argv[index]
23
+ if (arg === '--refresh') refresh = true
24
+ else if (arg === '--data' || arg === '--state') {
25
+ const value = argv[++index]
26
+ if (!value || value.startsWith('--')) { write(`${arg} needs a directory.\n`); return 2 }
27
+ values[arg.slice(2)] = resolve(value)
28
+ } else if (arg.startsWith('--')) { write(`Unknown option ${arg}.\n`); return 2 }
29
+ else args.push(arg)
30
+ }
31
+ const [command, target] = args
32
+ if (!command || command === 'help') { write(HUB_USAGE); return 0 }
33
+ if (!['list', 'show', 'install'].includes(command)) { write(HUB_USAGE); return 2 }
34
+ const root = join(dependencies.home ?? homedir(), '.xingular-harness')
35
+ const options: HarnessOptions = { dataRoot: values.data ?? join(root, 'data'), stateDir: values.state ?? join(root, 'state') }
36
+ const harness = dependencies.createHarness?.(options) ?? new Harness(options)
37
+ try {
38
+ const overview = await harness.hub.list(refresh)
39
+ if (overview.error) write(`Catalog refresh failed: ${overview.error} Using ${overview.origin} content.\n`)
40
+ if (command === 'list') {
41
+ write(`Library: ${options.dataRoot}\n${overview.entries.map((entry) => `${entry.id}@${entry.version} ${entry.category} ${entry.title}${overview.installed.some((receipt) => receipt.entry.id === entry.id && receipt.entry.version === entry.version) ? ' · installed' : ''}`).join('\n')}\n`)
42
+ return 0
43
+ }
44
+ const split = target?.lastIndexOf('@') ?? -1
45
+ if (!target || split < 1) throw new Error('Name an exact item as publisher/name@version. Run hub list to see versions.')
46
+ const id = target.slice(0, split)
47
+ const version = target.slice(split + 1)
48
+ const entry = await harness.hub.get(id, version)
49
+ if (command === 'show') {
50
+ const { bundled: _bundled, ...delivery } = entry.delivery.kind === 'pi-package' ? { ...entry.delivery, bundled: undefined } : entry.delivery
51
+ write(`${entry.title} (${target})\n${entry.description}\nPublisher: ${entry.publisher}\nLicense: ${entry.license}\nSource: ${entry.sourceUrl}\nDelivery: ${JSON.stringify(delivery)}\n`)
52
+ } else {
53
+ const receipt = await harness.hub.install(id, version)
54
+ write(receipt.document ? `Saved ${target} in ${options.dataRoot}/hub/documents/${receipt.document}. Open this library in a compatible application to apply it.\n` : `Installed ${target}, switched off. Attach it separately${entry.delivery.kind === 'pi-package' ? ' and review/trust any code' : ''} before using it.\n`)
55
+ }
56
+ return 0
57
+ } catch (error) { write(`${error instanceof Error ? error.message : String(error)}\n`); return 1 }
58
+ finally { await harness.close() }
59
+ }