@xingular/harness 0.1.7 → 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 (130) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/cli.js +5179 -2976
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +100 -10
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/index.ts +7 -2
  18. package/src/mcp/connections.ts +39 -12
  19. package/src/mcp/service.test.ts +49 -3
  20. package/src/mcp/service.ts +14 -3
  21. package/src/mcp/types.ts +10 -0
  22. package/src/memory/curation.test.ts +44 -1
  23. package/src/memory/dispatch.ts +5 -0
  24. package/src/memory/maintenance.test.ts +17 -1
  25. package/src/memory/maintenance.ts +169 -8
  26. package/src/memory/recall.ts +2 -1
  27. package/src/memory/trajectory.test.ts +178 -0
  28. package/src/memory/trajectory.ts +220 -0
  29. package/src/memory/wiki.ts +2 -1
  30. package/src/memory/wikiIndex.ts +3 -2
  31. package/src/plugins/builtin.ts +4 -2
  32. package/src/plugins/catalogue.test.ts +1 -1
  33. package/src/server/methods.ts +6 -0
  34. package/src/server/server.test.ts +20 -1
  35. package/src/sessions/approvals.test.ts +49 -0
  36. package/src/sessions/approvals.ts +26 -4
  37. package/src/sessions/conversation.test.ts +56 -0
  38. package/src/sessions/lineageJoin.ts +1 -1
  39. package/src/sessions/registry.ts +38 -7
  40. package/src/sessions/runStore.ts +14 -0
  41. package/src/sessions/runner.ts +9 -3
  42. package/src/sessions/sessions.test.ts +46 -7
  43. package/src/sessions/sessions.ts +31 -39
  44. package/src/sessions/subagentService.test.ts +159 -0
  45. package/src/sessions/subagentService.ts +236 -0
  46. package/src/sessions/subagents.ts +97 -0
  47. package/src/terminal/controller.ts +73 -10
  48. package/src/terminal/piExtension.ts +42 -6
  49. package/src/terminal/socket.test.ts +23 -0
  50. package/src/terminal/socket.ts +42 -18
  51. package/src/terminal/standalone.ts +12 -0
  52. package/src/terminal/terminal.test.ts +150 -15
  53. package/src/terminal/terminals.ts +113 -5
  54. package/src/terminal/trajectory.test.ts +66 -0
  55. package/src/terminal/trajectory.ts +44 -0
  56. package/src/terminal/tui/application.test.ts +184 -8
  57. package/src/terminal/tui/application.ts +243 -81
  58. package/src/terminal/tui/commands.test.ts +69 -9
  59. package/src/terminal/tui/commands.ts +89 -7
  60. package/src/terminal/tui/editor.test.ts +56 -27
  61. package/src/terminal/tui/editor.ts +49 -19
  62. package/src/terminal/tui/keybindings.test.ts +8 -0
  63. package/src/terminal/tui/keybindings.ts +10 -1
  64. package/src/terminal/tui/planMode.test.ts +10 -0
  65. package/src/terminal/tui/planMode.ts +12 -0
  66. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  67. package/src/terminal/tui/runtime.test.ts +10 -0
  68. package/src/terminal/tui/runtime.ts +24 -9
  69. package/src/terminal/tui/screen.test.ts +143 -0
  70. package/src/terminal/tui/screen.ts +121 -0
  71. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  72. package/src/terminal/tui/skillInvocation.ts +51 -0
  73. package/src/terminal/tui/status.test.ts +24 -46
  74. package/src/terminal/tui/status.ts +42 -69
  75. package/src/terminal/tui/theme.ts +18 -7
  76. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  77. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  78. package/src/terminal/tui/transcript.test.ts +14 -0
  79. package/src/terminal/tui/transcript.ts +11 -0
  80. package/src/terminal/tui/ui.ts +2 -2
  81. package/src/terminal/tui/welcome.ts +4 -4
  82. package/src/terminal/tui/workingStatus.test.ts +44 -0
  83. package/src/terminal/tui/workingStatus.ts +238 -0
  84. package/src/tools/definitions.ts +42 -1
  85. package/src/tools/maintenance.ts +6 -1
  86. package/src/tools/spawnSubagent.ts +40 -85
  87. package/src/tools/types.ts +2 -0
  88. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  89. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  90. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  91. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  92. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  93. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  95. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  100. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  108. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  116. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  118. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  119. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  120. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  121. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  122. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  124. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  125. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  127. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  128. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  129. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  130. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
package/src/harness.ts CHANGED
@@ -1,6 +1,7 @@
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
 
@@ -16,6 +17,7 @@ import { readJsonOr } from './core/fs'
16
17
  import { closeIndex } from './core/index'
17
18
  import { projectsOrNone } from './core/projects'
18
19
  import { ConnectedApps } from './mcp/service'
20
+ import type { AppOAuthClients } from './mcp/types'
19
21
  import { migrateConnectionScopes } from './mcp/scopeMigration'
20
22
  import type { MaintainerFactory } from './memory/maintainer'
21
23
  import { Maintenance } from './memory/maintenance'
@@ -76,6 +78,11 @@ export interface HarnessOptions {
76
78
  plugins?: PluginDescriptor[]
77
79
  /** Replaces Pi's own search for extensions, skills, prompts and themes, for tests. */
78
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
79
86
  /** Replaces how a plugin's hooks file is read as a module, for tests. */
80
87
  hookImporter?: (address: string) => Promise<unknown>
81
88
  }
@@ -124,6 +131,7 @@ export class Harness {
124
131
  /** Every part described as a plugin: the harness's own, then the application's. */
125
132
  descriptors: readonly PluginDescriptor[] = HARNESS_PLUGINS
126
133
  applicationPlugins: ReadonlySet<string> = new Set()
134
+ readonly subagents: Subagents
127
135
  readonly sessions: Sessions
128
136
  readonly maintenance: Maintenance
129
137
  readonly terminals: Terminals
@@ -152,7 +160,7 @@ export class Harness {
152
160
  redact: (event) => this.apps.connections.redactEvent(event),
153
161
  ended: (runId, sessionId) => this.apps.revokeRun(runId, sessionId)
154
162
  })
155
- 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 })
156
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) })
157
165
  this.hooks = new Hooks({
158
166
  stateDir: this.stateDir,
@@ -182,7 +190,9 @@ export class Harness {
182
190
  reloadTimer.unref?.()
183
191
  })
184
192
  this.describe(options.plugins ?? [])
193
+ this.subagents = new Subagents(this.env, this.runs, (input) => this.sessions.run(input))
185
194
  this.sessions = new Sessions({
195
+ subagents: this.subagents,
186
196
  env: this.env,
187
197
  accounts: this.accounts,
188
198
  runs: this.runs,
@@ -193,7 +203,7 @@ export class Harness {
193
203
  factory: options.sessionFactory
194
204
  })
195
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 })
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 })
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 })
197
207
  this.ready = this.apps.start().catch((error) => log('connected apps', error instanceof Error ? error.message : String(error)))
198
208
  this.runs.listen((event, run) => {
199
209
  this.accounts.onRunEvent(event, run)
package/src/index.ts CHANGED
@@ -47,7 +47,7 @@ export type { Effort } from './sessions/runner'
47
47
 
48
48
  export { describeTool, WIKI_TOOLS, WIKI_TOOL_NAMES, type WikiCall, type WikiToolDefinition } from './tools/definitions'
49
49
  export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
50
- export { SPAWN_SUBAGENT, SUBAGENT_RELATION, DEFAULT_SUBAGENT_DEPTH, MAX_SUBAGENT_DEPTH, type SubagentOptions } from './tools/spawnSubagent'
50
+ export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from './tools/spawnSubagent'
51
51
  export { maintenanceTools, MAINTENANCE_PLUGIN, MAINTENANCE_TOOL_NAMES } from './tools/maintenance'
52
52
  export type { CurationJob } from './memory/curation'
53
53
  export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
@@ -55,7 +55,7 @@ export { registryTools, REGISTRY_WRITE, PLUGIN_WRITE, REGISTRY_WRITE_PLUGIN, typ
55
55
  export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
56
56
 
57
57
  export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
58
- export type { Connection, ConnectionInput } from './mcp/types'
58
+ export type { Connection, ConnectionInput, AppOAuthClients } from './mcp/types'
59
59
 
60
60
  export { configureBuiltins, type RegistryItem, type RegistryState, type SkillBundleInput } from './skills/registry'
61
61
 
@@ -73,3 +73,8 @@ export { methods } from './server/methods'
73
73
  export { remoteDirectory, remotePolicy, remoteSecrets, remoteTools } from './server/remote'
74
74
  export { PROTOCOL_VERSION, RpcPeer, stdioTransport, type Handler, type Transport } from './server/rpc'
75
75
  export { attach, serve, type Served } from './server/serve'
76
+
77
+ export { DEFAULT_SUBAGENT_DEPTH, MAX_SUBAGENT_DEPTH, type SubagentOptions, type SubagentInput } from './sessions/subagents'
78
+
79
+ export { Subagents } from './sessions/subagentService'
80
+ export { WAIT_SUBAGENTS, type SubagentResult, type SubagentSelection } from './sessions/subagents'
@@ -12,7 +12,7 @@ import { isDir } from '../memory/store'
12
12
  import type { RunEvent } from '../sessions/contracts'
13
13
  import * as attachments from '../plugins/attachments'
14
14
  import { connectionScopeMigrationComplete } from './scopeMigration'
15
- import { connectionCapabilityId, connectionInputSchema, connectionSchema, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
15
+ import { connectionCapabilityId, connectionInputSchema, connectionSchema, type AppOAuthClients, type Connection, type ConnectionInput, type LaunchConfig, type Scope } from './types'
16
16
 
17
17
  const OAUTH_STATE = 'oauth'
18
18
  const OAUTH_CLIENT_ID = 'oauth_client_id'
@@ -175,7 +175,8 @@ export class Connections {
175
175
  stateDir: string,
176
176
  private readonly secrets: SecretStore,
177
177
  private readonly bus: Bus,
178
- private readonly attachmentRoot: () => string | null = () => null
178
+ private readonly attachmentRoot: () => string | null = () => null,
179
+ private readonly appClients: AppOAuthClients | null = null
179
180
  ) {
180
181
  this.path = join(stateDir, 'connections.json')
181
182
  }
@@ -468,23 +469,49 @@ export class Connections {
468
469
 
469
470
  // ---- OAuth state --------------------------------------------------------------------------
470
471
 
471
- private requireOauth(id: string): void {
472
- if (!this.list().some((item) => item.id === id && item.kind === 'remote' && item.auth === 'oauth')) throw notFound('Connection')
472
+ private requireOauth(id: string): Connection {
473
+ const connection = this.list().find((item) => item.id === id && item.kind === 'remote' && item.auth === 'oauth')
474
+ if (!connection) throw notFound('Connection')
475
+ return connection
473
476
  }
474
477
 
475
- /** The saved sign-in of a connection, with the client the user registered, if any. */
478
+ /** The saved sign-in of a connection, with the client the user registered, or the application's own. */
476
479
  async oauthLoad(id: string): Promise<Record<string, unknown>> {
477
- this.requireOauth(id)
480
+ const connection = this.requireOauth(id)
478
481
  const saved = (parsedContainer((await this.secret(id, OAUTH_STATE)) ?? '') ?? {}) as Record<string, unknown>
479
482
  const state = Array.isArray(saved) ? {} : saved
480
- const clientId = await this.secret(id, OAUTH_CLIENT_ID)
481
- if (clientId) {
482
- const clientSecret = await this.secret(id, OAUTH_CLIENT_SECRET)
483
- state.staticClient = { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) }
483
+ delete state.staticClient
484
+ if (connection.oauthClient === 'app') {
485
+ const client = await this.appClient(connection)
486
+ if (client) state.staticClient = { client_id: client.clientId, ...(client.clientSecret ? { client_secret: client.clientSecret } : {}) }
487
+ } else if (connection.oauthClient === 'user' || connection.oauthClient === null) {
488
+ const clientId = await this.secret(id, OAUTH_CLIENT_ID)
489
+ if (clientId) {
490
+ const clientSecret = await this.secret(id, OAUTH_CLIENT_SECRET)
491
+ state.staticClient = { client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}) }
492
+ }
484
493
  }
485
494
  return state
486
495
  }
487
496
 
497
+ /** The client this application registered for a catalog entry, if it has one. */
498
+ private async appClient(connection: Connection): Promise<{ clientId: string; clientSecret?: string } | null> {
499
+ if (!connection.catalogId) return null
500
+ return (await this.appClients?.get(connection.catalogId)) ?? null
501
+ }
502
+
503
+ /** Whether a sign-in can be started: a dynamic server registers itself, the others need a client. */
504
+ async hasSignInClient(connection: Connection): Promise<boolean> {
505
+ if (connection.oauthClient === 'user') return (await this.secret(connection.id, OAUTH_CLIENT_ID)) !== null
506
+ if (connection.oauthClient === 'app') return (await this.appClient(connection)) !== null
507
+ return true
508
+ }
509
+
510
+ /** The catalog entries this application can sign in for without asking for a client. */
511
+ availableAppClients(): string[] {
512
+ return this.appClients?.ids() ?? []
513
+ }
514
+
488
515
  async oauthSave(id: string, state: Record<string, unknown>): Promise<void> {
489
516
  this.requireOauth(id)
490
517
  const { staticClient: _static, ...saved } = state ?? {}
@@ -515,7 +542,7 @@ export class Connections {
515
542
  if (remote) {
516
543
  if (!webUrl(connection.url)) throw invalid('Server URL must be an https:// address.')
517
544
  if (!['none', 'headers', 'oauth'].includes(connection.auth)) throw invalid('Unsupported sign-in method.')
518
- if (connection.oauthClient && !['dynamic', 'user'].includes(connection.oauthClient)) throw invalid('Unsupported OAuth client.')
545
+ if (connection.oauthClient && !['dynamic', 'user', 'app'].includes(connection.oauthClient)) throw invalid('Unsupported OAuth client.')
519
546
  Object.assign(connection, { command: '', args: [], cwd: null })
520
547
  if (connection.auth !== 'oauth') Object.assign(connection, { oauthClient: null, oauthScopes: [] })
521
548
  } else {
@@ -533,7 +560,7 @@ export class Connections {
533
560
  connection.envKeys = [...(existing?.envKeys ?? [])]
534
561
  connection.headerKeys = [...(existing?.headerKeys ?? [])]
535
562
  // A sign-in belongs to one server and one client; never carry it to another.
536
- const sameServer = (existing?.url ?? null) === connection.url && (existing?.auth ?? '') === connection.auth && !oauthCredentials
563
+ const sameServer = (existing?.url ?? null) === connection.url && (existing?.auth ?? '') === connection.auth && existing?.oauthClient === connection.oauthClient && existing?.catalogId === connection.catalogId && !oauthCredentials
537
564
  if (!sameServer || connection.auth !== 'oauth') await this.forgetSignIn(connection.id)
538
565
  connection.authState = connection.auth === 'oauth' ? (sameServer ? (existing?.authState ?? 'signed_out') : 'signed_out') : null
539
566
  if (oauthCredentials) {
@@ -8,7 +8,7 @@ import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelconte
8
8
  import { afterEach, describe, expect, it } from 'vitest'
9
9
 
10
10
  import { memorySecretStore } from '../accounts/secrets'
11
- import { createHarness, type Harness } from '../harness'
11
+ import { createHarness, type Harness, type HarnessOptions } from '../harness'
12
12
  import type { RunEvent } from '../sessions/contracts'
13
13
  import type { ToolPolicy } from '../sessions/policy'
14
14
  import type { PiSessionLike } from '../sessions/runner'
@@ -106,12 +106,12 @@ afterEach(async () => {
106
106
  const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
107
107
 
108
108
  /** A harness with one live run whose session keeps going until the test releases it. */
109
- async function live(policy: ToolPolicy = {}) {
109
+ async function live(policy: ToolPolicy = {}, extra: Partial<HarnessOptions> = {}) {
110
110
  let release: () => void = () => undefined
111
111
  const stateDir = mkdtempSync(join(tmpdir(), 'harness-mcp-state-'))
112
112
  const secrets = memorySecretStore()
113
113
  const session: PiSessionLike = { sessionId: 'native', subscribe: () => () => undefined, prompt: () => new Promise<void>((resolve) => (release = resolve)), sendCustomMessage: async () => undefined, abort: async () => release(), dispose: () => undefined }
114
- const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-mcp-data-')), stateDir, secrets, policy, sessionFactory: async () => session })
114
+ const harness = createHarness({ dataRoot: mkdtempSync(join(tmpdir(), 'harness-mcp-data-')), stateDir, secrets, policy, sessionFactory: async () => session, ...extra })
115
115
  open.push(harness)
116
116
  harness.accounts.create('Main', 'apiKey')
117
117
  const events: RunEvent[] = []
@@ -277,6 +277,7 @@ describe('remote connected apps', () => {
277
277
  await expect(harness.apps.save({ name: '', kind: 'local', command: 'x' })).rejects.toThrow('A name, executable')
278
278
  await expect(harness.apps.save({ ...local, env: { 'BAD NAME': 'x' } })).rejects.toThrow('environment variable')
279
279
  await expect(harness.apps.save({ ...remote, url: 'https://x.example/mcp', auth: 'headers', headers: { 'X-Key': 'a\nb' } })).rejects.toThrow('header')
280
+ await expect(harness.apps.save({ ...remote, url: 'https://x.example/mcp', auth: 'oauth', oauthClient: 'whoever' })).rejects.toThrow('Unsupported OAuth client')
280
281
  const workspace = await harness.apps.save({ name: 'Xingular Workspace', kind: 'workspace', command: process.execPath, enabled: true, global: true })
281
282
  expect(() => harness.apps.connections.allowTool(workspace.id, 'x_workspace_mutation_apply')).toThrow('one at a time')
282
283
  await harness.apps.remove(workspace.id)
@@ -373,4 +374,49 @@ describe('signing in to a remote connected app', () => {
373
374
  expect(server.seen.tokenRequests[0]).toContain('fixture-code')
374
375
  expect(server.seen.tokenRequests[0]).toMatch(/own-client|b3duLWNsaWVudDpvd24tc2VjcmV0/)
375
376
  })
377
+
378
+ it('signs in with the application\'s own client, keeps it out of storage, and offers it only for that catalog entry', async () => {
379
+ const server = await oauthFixture()
380
+ const { harness, secrets } = await live({}, { oauthClients: { ids: () => ['fixture-app'], get: async (id) => (id === 'fixture-app' ? { clientId: 'app-client', clientSecret: 'app-secret' } : null) } })
381
+ harness.bus.on((notice) => {
382
+ if (notice.type === 'connections.openUrl') void fetch(String(notice.url))
383
+ })
384
+ expect(harness.apps.appClients()).toEqual({ catalogIds: ['fixture-app'] })
385
+ const saved = await harness.apps.save({ name: 'App client', kind: 'remote', enabled: true, global: true, url: server.url, auth: 'oauth', oauthClient: 'app', catalogId: 'fixture-app', oauthScopes: ['read'] })
386
+ expect((await harness.apps.signIn(saved.id)).authState).toBe('signed_in')
387
+ // No client of its own is registered, and the application's client is what the provider sees.
388
+ expect(server.seen.registered).toBe(0)
389
+ expect(server.seen.authorizeUrls[0].searchParams.get('client_id')).toBe('app-client')
390
+ // Neither the saved connection nor the secret store ever holds the application's client secret.
391
+ expect(JSON.stringify(harness.apps.connections.list())).not.toContain('app-secret')
392
+ expect(JSON.stringify([...secrets.values.entries()])).not.toContain('app-secret')
393
+ expect(JSON.parse(secrets.values.get(`mcp--${saved.id}/oauth`)!).tokens).toMatchObject({ refresh_token: 'fixture-refresh' })
394
+ })
395
+
396
+ it('uses the application client and resets sign-in when switching from a saved user client', async () => {
397
+ const server = await oauthFixture()
398
+ const { harness, secrets } = await live({}, { oauthClients: { ids: () => ['fixture-app'], get: async (id) => (id === 'fixture-app' ? { clientId: 'app-client', clientSecret: 'app-secret' } : null) } })
399
+ harness.bus.on((notice) => {
400
+ if (notice.type === 'connections.openUrl') void fetch(String(notice.url))
401
+ })
402
+ const user = await harness.apps.save({ name: 'Switched client', kind: 'remote', enabled: true, global: true, url: server.url, auth: 'oauth', oauthClient: 'user', oauthCredentials: { clientId: 'user-client', clientSecret: 'user-secret' } })
403
+ const signedInUser = await harness.apps.signIn(user.id)
404
+ expect(signedInUser.authState).toBe('signed_in')
405
+
406
+ const app = await harness.apps.save({ ...user, oauthClient: 'app', catalogId: 'fixture-app' })
407
+ expect(app.authState).toBe('signed_out')
408
+ expect(secrets.values.has(`mcp--${user.id}/oauth`)).toBe(false)
409
+ await harness.apps.signIn(user.id)
410
+
411
+ expect(server.seen.authorizeUrls.map((url) => url.searchParams.get('client_id'))).toEqual(['user-client', 'app-client'])
412
+ expect(server.seen.tokenRequests[1]).toMatch(/app-client|YXBwLWNsaWVudDphcHAtc2VjcmV0/)
413
+ })
414
+
415
+ it('refuses an application sign-in this build has no client for, naming the reason', async () => {
416
+ const { harness } = await live()
417
+ const saved = await harness.apps.save({ name: 'No client', kind: 'remote', enabled: true, global: true, url: 'https://example.com/mcp', auth: 'oauth', oauthClient: 'app', catalogId: 'google-drive', oauthScopes: [] })
418
+ expect(harness.apps.appClients()).toEqual({ catalogIds: [] })
419
+ await expect(harness.apps.signIn(saved.id)).rejects.toThrow(/no app sign-in/)
420
+ expect(harness.apps.connections.find(saved.id).authState).toBe('signed_out')
421
+ })
376
422
  })
@@ -20,7 +20,7 @@ import type { ToolSession, ToolSpec } from '../tools/types'
20
20
  import { allowed, Connections, webUrl } from './connections'
21
21
  import { isSignInRequired, McpManager, signInRequired } from './manager'
22
22
  import { signIn, type OAuthHost } from './oauth'
23
- import type { Connection, HttpConfig, LaunchConfig, McpSource, McpTrace } from './types'
23
+ import type { AppOAuthClients, Connection, HttpConfig, LaunchConfig, McpSource, McpTrace } from './types'
24
24
 
25
25
  /** A session's access to the broker. A terminal session presents `token`; an SDK session holds the object. */
26
26
  export interface BrokerSession {
@@ -118,6 +118,8 @@ export interface ConnectedAppsOptions {
118
118
  bus: Bus
119
119
  runs: RunRegistry
120
120
  policy: ToolPolicy
121
+ /** The application's own registered OAuth clients, if it has any. */
122
+ oauthClients?: AppOAuthClients
121
123
  }
122
124
 
123
125
  /** The scope a plugin hook's connections are opened under: they belong to no session. */
@@ -134,7 +136,7 @@ export class ConnectedApps implements SessionBroker {
134
136
  private brokered: BrokeredTools | null = null
135
137
 
136
138
  constructor(private readonly options: ConnectedAppsOptions) {
137
- this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.root)
139
+ this.connections = new Connections(options.stateDir, options.secrets, options.bus, () => options.env.root, options.oauthClients ?? null)
138
140
  this.oauth = {
139
141
  clientName: options.env.productName,
140
142
  load: (id) => this.connections.oauthLoad(id),
@@ -469,7 +471,11 @@ export class ConnectedApps implements SessionBroker {
469
471
  async signIn(connectionId: string): Promise<Connection> {
470
472
  const connection = this.connections.find(connectionId)
471
473
  if (connection.kind !== 'remote' || connection.auth !== 'oauth') throw invalid('This connection does not use browser sign-in.')
472
- if (connection.oauthClient === 'user' && !(await this.connections.hasOauthClient(connection.id))) throw invalid('Add your OAuth client ID before signing in.')
474
+ if (!(await this.connections.hasSignInClient(connection))) {
475
+ throw invalid(connection.oauthClient === 'app'
476
+ ? 'This connector has no app sign-in in this build. Enable its OAuth client or use your own client.'
477
+ : 'Add your OAuth client ID before signing in.')
478
+ }
473
479
  await signIn(this.oauth, (await this.connections.launch(connection)) as HttpConfig)
474
480
  const updated = this.connections.update(connectionId, (item) => Object.assign(item, { authState: 'signed_in', error: null }))
475
481
  await this.manager.close(connectionId)
@@ -477,6 +483,11 @@ export class ConnectedApps implements SessionBroker {
477
483
  return updated
478
484
  }
479
485
 
486
+ /** The catalog entries this application can sign in for, so a client need not be added by hand. */
487
+ appClients(): { catalogIds: string[] } {
488
+ return { catalogIds: this.connections.availableAppClients() }
489
+ }
490
+
480
491
  async signOut(connectionId: string): Promise<Connection> {
481
492
  const updated = await this.connections.signOut(connectionId)
482
493
  this.cancelConnection(connectionId)
package/src/mcp/types.ts CHANGED
@@ -7,6 +7,16 @@ import { z } from 'zod'
7
7
  export const MCP_OAUTH_PORT = 47823
8
8
  export const MCP_OAUTH_REDIRECT_URL = `http://127.0.0.1:${MCP_OAUTH_PORT}/oauth/callback`
9
9
 
10
+ /**
11
+ * The application's own registered OAuth clients, by catalog entry. A client supplied here is
12
+ * used for that sign-in only and is never written to the connection or the secret store.
13
+ */
14
+ export interface AppOAuthClients {
15
+ /** The catalog entries this application can sign in for. */
16
+ ids(): string[]
17
+ get(catalogId: string): Promise<{ clientId: string; clientSecret?: string } | null>
18
+ }
19
+
10
20
  /** Stable attachment key for a connected app's capability switch. */
11
21
  export const connectionCapabilityId = (id: string): string => `connected-apps/connection/${id}`
12
22
 
@@ -11,9 +11,10 @@ import type { ToolSession } from '../tools/types'
11
11
  import * as blocks from './blocks'
12
12
  import { memoryEdit, propose } from './api'
13
13
  import { enqueueCuration } from './curation'
14
- import { findMemory } from './facts'
14
+ import { findMemory, saveAsUser } from './facts'
15
15
  import * as registry from '../skills/registry'
16
16
  import * as attachments from '../plugins/attachments'
17
+ import { listInbox } from './operations'
17
18
  import { wikiDir } from './store'
18
19
 
19
20
  const opened: Harness[] = []
@@ -57,6 +58,48 @@ test('a shared fact is proposed without writing, then validated against its orig
57
58
  expect(h.maintenance.applyJob(curator, job.id, undefined, 'Retry receipt').receipt).toEqual(receipt.receipt)
58
59
  })
59
60
 
61
+ test('an assigned proposal batch is read together and committed as one reconciled Wiki operation', async () => {
62
+ const h = setup(); await origin(h)
63
+ propose(h.env, self, { kind: 'fact', title: 'Build package', body: 'Prefer pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
64
+ propose(h.env, self, { kind: 'fact', title: 'Build tool', body: 'Use pnpm for builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
65
+ const ids = h.maintenance.jobs().filter(job => job.input.kind === 'proposal').map(job => job.id)
66
+ const batchCurator = { ...curator, maintenanceJobIds: ids }
67
+ expect(h.maintenance.readBatch(batchCurator).jobs).toHaveLength(2)
68
+
69
+ const applied = h.maintenance.applyBatch(batchCurator, ids.map(id => ({ id, decision: 'apply' as const, reason: 'Merge duplicate statements into one evidence-backed fact.' })), {
70
+ pages: [],
71
+ memories: [{ title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
72
+ blocks: [],
73
+ flags: []
74
+ })
75
+
76
+ expect(applied.operationId).toMatch(/^batch-/)
77
+ expect(applied.jobs.map(job => job.status)).toEqual(['completed', 'completed'])
78
+ expect(applied.jobs.filter(job => job.receipt?.operationId === applied.operationId)).toHaveLength(1)
79
+ expect(findMemory(wikiDir(h.env.root), 'build-tool').body.trim()).toBe('Prefer pnpm for builds.')
80
+ expect(listInbox(wikiDir(h.env.root)).filter(item => item.operationId === applied.operationId)).toHaveLength(1)
81
+ })
82
+
83
+ test('a batch parks updates whose captured Wiki target changed before review', async () => {
84
+ const h = setup(); await origin(h)
85
+ const wiki = wikiDir(h.env.root)
86
+ saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'Use pnpm for builds.' })
87
+ propose(h.env, self, { kind: 'fact', id: 'build-tool', title: 'Build tool', body: 'Prefer pnpm for all builds.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'Prefer pnpm for all builds.' }] })
88
+ const id = h.maintenance.jobs().find(job => job.input.kind === 'proposal')!.id
89
+ saveAsUser(wiki, { id: 'build-tool', title: 'Build tool', kind: 'preference', namespace: '/', body: 'The user changed this preference.' })
90
+
91
+ const result = h.maintenance.applyBatch({ ...curator, maintenanceJobIds: [id] }, [{ id, decision: 'apply', reason: 'Review the proposed update against its evidence.' }], {
92
+ pages: [],
93
+ memories: [{ id: 'build-tool', title: 'Build tool', kind: 'preference', body: 'Prefer pnpm for all builds.', namespace: '/', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'origin', excerpt: 'Prefer pnpm for all builds.' }], links: [] }],
94
+ blocks: [],
95
+ flags: []
96
+ })
97
+
98
+ expect(result.jobs[0].status).toBe('needs_user')
99
+ expect(result.operationId).toBeNull()
100
+ expect(findMemory(wiki, 'build-tool').body.trim()).toBe('The user changed this preference.')
101
+ })
102
+
60
103
  test('invented evidence is parked and a read-only memory block is not rewritten', async () => {
61
104
  const h = setup(); await origin(h)
62
105
  propose(h.env, self, { kind: 'fact', title: 'Invented', body: 'Use another tool.', confidence: 'high', sourceRefs: [{ kind: 'run', ref: 'current', excerpt: 'This was never said anywhere.' }] })
@@ -7,6 +7,7 @@ import { launchText, load, render } from '../prompt/compile'
7
7
  import * as api from './api'
8
8
  import type { CallerContext } from './caller'
9
9
  import * as recall from './recall'
10
+ import * as trajectory from './trajectory'
10
11
  import { reindexQuietly } from './wikiIndex'
11
12
 
12
13
  export type DispatchEnv = HarnessEnv & { bus?: Bus }
@@ -22,6 +23,10 @@ export function dispatch(env: DispatchEnv, context: CallerContext, call: string,
22
23
  return recall.searchTool(env, context, params)
23
24
  case 'recall.read':
24
25
  return recall.readTool(env, context, params)
26
+ case 'trajectory.search':
27
+ return trajectory.searchTool(env, context, params)
28
+ case 'trajectory.read':
29
+ return trajectory.readTool(env, context, params)
25
30
  case 'memory.append':
26
31
  case 'memory.replace':
27
32
  case 'memory.rethink': {
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
5
5
 
6
6
  import { memorySecretStore } from '../accounts/secrets'
7
7
  import { writeJson } from '../core/fs'
8
- import type { Notice } from '../core/bus'
8
+ import { setLogSink, type Notice } from '../core/bus'
9
9
  import { createHarness, type Harness } from '../harness'
10
10
  import type { PiSessionLike } from '../sessions/runner'
11
11
  import * as blocks from './blocks'
@@ -132,6 +132,22 @@ describe('the memory lifecycle', () => {
132
132
  expect([chatUpdatesPaused({}), chatUpdatesPaused({ autoMaintenanceChat: true }), chatUpdatesPaused({ autoMaintenanceChat: false })]).toEqual([false, false, true])
133
133
  })
134
134
 
135
+ it('a code run outside any project is not maintained, and it leaves no log line', async () => {
136
+ const lines: string[] = []
137
+ setLogSink((text) => void lines.push(text))
138
+ try {
139
+ let jobs = 0
140
+ const { harness } = setup(async () => ({ prompt: async () => void (jobs += 1), dispose: () => undefined, error: () => null }))
141
+ await harness.sessions.run({ run: { id: 'code-1', sessionId: 'nora', mode: 'code' },cwd: harness.env.root, sessionDir: join(harness.env.root, 'history'), text: 'Which shell do we use?', caller: { agentId: 'nora', agentName: 'Nora', actor: 'Code' }, tools: { allow: [] } })
142
+ await new Promise((resolve) => setTimeout(resolve, 30))
143
+ await harness.maintenance.settled()
144
+ expect(jobs).toBe(0)
145
+ expect(lines.filter((line) => line.includes('maintenance'))).toEqual([])
146
+ } finally {
147
+ setLogSink(null)
148
+ }
149
+ })
150
+
135
151
  it('an imported source is read once and its proposals are checked against its text', async () => {
136
152
  const { harness, wiki } = setup(async ({ kind, collect }) => ({
137
153
  prompt: async () => {