@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.
- package/CHANGELOG.md +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- package/src/tools/types.ts +2 -0
package/src/accounts/accounts.ts
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
.
|
|
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(
|
|
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)
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
443
|
-
chmodSync(path, 0o600)
|
|
621
|
+
this.terminalSnapshots.set(account.id, data)
|
|
444
622
|
} catch (error) {
|
|
445
623
|
this.terminals.delete(account.id)
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
data = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
689
|
+
names = readdirSync(folder)
|
|
463
690
|
} catch {
|
|
464
|
-
|
|
691
|
+
return false
|
|
465
692
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
terminal Start the interactive
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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()
|