@xingular/harness 0.1.7 → 2.1.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 (135) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +36 -13
  3. package/dist/cli.js +10728 -8033
  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 +70 -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 +109 -13
  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/hub/hub.test.ts +49 -1
  18. package/src/hub/hub.ts +122 -6
  19. package/src/hub/schema.ts +13 -1
  20. package/src/index.ts +7 -2
  21. package/src/mcp/connections.ts +39 -12
  22. package/src/mcp/service.test.ts +49 -3
  23. package/src/mcp/service.ts +14 -3
  24. package/src/mcp/types.ts +10 -0
  25. package/src/memory/curation.test.ts +44 -1
  26. package/src/memory/dispatch.ts +5 -0
  27. package/src/memory/maintenance.test.ts +17 -1
  28. package/src/memory/maintenance.ts +169 -8
  29. package/src/memory/recall.ts +2 -1
  30. package/src/memory/trajectory.test.ts +178 -0
  31. package/src/memory/trajectory.ts +220 -0
  32. package/src/memory/wiki.ts +2 -1
  33. package/src/memory/wikiIndex.ts +3 -2
  34. package/src/plugins/builtin.ts +4 -2
  35. package/src/plugins/catalogue.test.ts +1 -1
  36. package/src/server/methods.ts +8 -0
  37. package/src/server/server.test.ts +20 -1
  38. package/src/sessions/approvals.test.ts +49 -0
  39. package/src/sessions/approvals.ts +26 -4
  40. package/src/sessions/conversation.test.ts +56 -0
  41. package/src/sessions/lineageJoin.ts +1 -1
  42. package/src/sessions/planMode.test.ts +40 -0
  43. package/src/sessions/planMode.ts +57 -0
  44. package/src/sessions/registry.ts +38 -7
  45. package/src/sessions/runStore.ts +14 -0
  46. package/src/sessions/runner.ts +9 -3
  47. package/src/sessions/sessions.test.ts +69 -7
  48. package/src/sessions/sessions.ts +47 -39
  49. package/src/sessions/subagentService.test.ts +159 -0
  50. package/src/sessions/subagentService.ts +236 -0
  51. package/src/sessions/subagents.ts +97 -0
  52. package/src/terminal/controller.ts +73 -10
  53. package/src/terminal/piExtension.ts +42 -6
  54. package/src/terminal/socket.test.ts +23 -0
  55. package/src/terminal/socket.ts +42 -18
  56. package/src/terminal/standalone.ts +12 -0
  57. package/src/terminal/terminal.test.ts +150 -15
  58. package/src/terminal/terminals.ts +113 -5
  59. package/src/terminal/trajectory.test.ts +66 -0
  60. package/src/terminal/trajectory.ts +44 -0
  61. package/src/terminal/tui/application.test.ts +206 -9
  62. package/src/terminal/tui/application.ts +251 -81
  63. package/src/terminal/tui/commands.test.ts +70 -9
  64. package/src/terminal/tui/commands.ts +99 -7
  65. package/src/terminal/tui/editor.test.ts +56 -27
  66. package/src/terminal/tui/editor.ts +64 -19
  67. package/src/terminal/tui/keybindings.test.ts +7 -0
  68. package/src/terminal/tui/keybindings.ts +9 -1
  69. package/src/terminal/tui/planMode.test.ts +10 -0
  70. package/src/terminal/tui/planMode.ts +2 -0
  71. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  72. package/src/terminal/tui/runtime.test.ts +10 -0
  73. package/src/terminal/tui/runtime.ts +24 -9
  74. package/src/terminal/tui/screen.test.ts +143 -0
  75. package/src/terminal/tui/screen.ts +121 -0
  76. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  77. package/src/terminal/tui/skillInvocation.ts +51 -0
  78. package/src/terminal/tui/status.test.ts +24 -46
  79. package/src/terminal/tui/status.ts +42 -69
  80. package/src/terminal/tui/theme.ts +18 -7
  81. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  82. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  83. package/src/terminal/tui/transcript.test.ts +14 -0
  84. package/src/terminal/tui/transcript.ts +11 -0
  85. package/src/terminal/tui/ui.ts +2 -2
  86. package/src/terminal/tui/welcome.ts +4 -4
  87. package/src/terminal/tui/workingStatus.test.ts +44 -0
  88. package/src/terminal/tui/workingStatus.ts +238 -0
  89. package/src/tools/definitions.ts +42 -1
  90. package/src/tools/maintenance.ts +6 -1
  91. package/src/tools/spawnSubagent.ts +40 -85
  92. package/src/tools/types.ts +4 -0
  93. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  95. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  100. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  108. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  116. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  118. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  119. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  120. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  121. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  122. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  124. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  125. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  127. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  128. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  129. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  130. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  131. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  132. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  133. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  134. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  135. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -0,0 +1,220 @@
1
+ // Read-only views over the canonical run records and validated event logs.
2
+ import type { HarnessEnv } from '../core/env'
3
+ import { invalid, notFound } from '../core/errors'
4
+ import { listProjects } from '../core/projects'
5
+ import { compareText } from '../core/text'
6
+ import { scrub } from '../accounts/store'
7
+ import { scrubSensitive } from '../mcp/connections'
8
+ import { isActiveStatus, runStatusSchema, type RunEvent, type RunRecord } from '../sessions/contracts'
9
+ import { readAllRuns } from '../sessions/runIndex'
10
+ import { findRun, snapshot } from '../sessions/runStore'
11
+ import type { CallerContext } from './caller'
12
+
13
+ const SEARCH_DEFAULT = 10
14
+ const SEARCH_MAX = 30
15
+ const READ_DEFAULT = 8
16
+ const READ_MAX = 10
17
+ const EVENT_MAX_CHARS = 18_000
18
+ const PAGE_MAX_CHARS = 48_000
19
+
20
+ export type Params = Record<string, unknown> | null | undefined
21
+
22
+ function textParam(params: Params, key: string): string {
23
+ const value = params?.[key]
24
+ return typeof value === 'string' ? value.trim() : ''
25
+ }
26
+
27
+ function boundedInteger(params: Params, key: string, fallback: number, minimum: number, maximum: number): number {
28
+ const value = params?.[key]
29
+ if (value === undefined) return fallback
30
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < minimum || value > maximum) {
31
+ throw invalid(`${key} must be an integer from ${minimum} to ${maximum}.`)
32
+ }
33
+ return value
34
+ }
35
+
36
+ function dateValue(params: Params, key: string): { text: string; timestamp: number } | null {
37
+ const value = textParam(params, key)
38
+ if (!value) return null
39
+ if (!/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(value)) throw invalid(`${key} must be a YYYY-MM-DD date or ISO timestamp.`)
40
+ const timestamp = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}${key === 'until' ? 'T23:59:59.999Z' : 'T00:00:00.000Z'}` : value)
41
+ if (!Number.isFinite(timestamp)) throw invalid(`${key} must be a valid date or ISO timestamp.`)
42
+ return { text: value, timestamp }
43
+ }
44
+
45
+ interface Cursor {
46
+ startedAt: string
47
+ id: string
48
+ }
49
+
50
+ function decodeCursor(value: string): Cursor | null {
51
+ if (!value) return null
52
+ try {
53
+ const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Partial<Cursor>
54
+ if (typeof parsed.startedAt !== 'string' || !parsed.startedAt || typeof parsed.id !== 'string' || !parsed.id) throw new Error('invalid cursor')
55
+ return { startedAt: parsed.startedAt, id: parsed.id }
56
+ } catch {
57
+ throw invalid('The trajectory cursor is invalid. Start a new search without it.')
58
+ }
59
+ }
60
+
61
+ function encodeCursor(run: RunRecord): string {
62
+ return Buffer.from(JSON.stringify({ startedAt: run.startedAt, id: run.id })).toString('base64url')
63
+ }
64
+
65
+ function hidden(env: HarnessEnv, context: CallerContext, run: RunRecord): boolean {
66
+ const excluded = context.recallExcludeAgents ?? []
67
+ if (!excluded.length) return false
68
+ return excluded.includes(run.agentId ?? '') || excluded.includes(run.sessionId) || excluded.includes(env.directory.sessionAgent(run.sessionId) ?? '')
69
+ }
70
+
71
+ function afterCursor(run: RunRecord, cursor: Cursor): boolean {
72
+ return run.startedAt < cursor.startedAt || (run.startedAt === cursor.startedAt && run.id < cursor.id)
73
+ }
74
+
75
+ function summary(env: HarnessEnv, run: RunRecord, projects: ReturnType<typeof listProjects>) {
76
+ const project = projects.find((entry) => entry.id === run.projectId)
77
+ const goal = run.goal?.trim() ?? ''
78
+ return {
79
+ id: run.id,
80
+ agentId: run.agentId,
81
+ agentName: run.agentId ? env.directory.agentName(run.agentId) : null,
82
+ project: project ? { id: project.id, name: project.name, slug: project.slug } : null,
83
+ mode: run.mode,
84
+ status: run.status,
85
+ goal: goal.length > 500 ? `${goal.slice(0, 497)}…` : goal || null,
86
+ startedAt: run.startedAt,
87
+ endedAt: run.endedAt,
88
+ parentRunId: run.parentRunId,
89
+ relation: run.relation
90
+ }
91
+ }
92
+
93
+ /** `trajectory.search`: structured list/search over the current Harness data root. */
94
+ export function searchTool(env: HarnessEnv, context: CallerContext, params: Params): string {
95
+ const agent = textParam(params, 'agent')
96
+ const projectKey = textParam(params, 'project')
97
+ const mode = textParam(params, 'mode')
98
+ const status = textParam(params, 'status')
99
+ const since = dateValue(params, 'since')
100
+ const until = dateValue(params, 'until')
101
+ const limit = boundedInteger(params, 'limit', SEARCH_DEFAULT, 1, SEARCH_MAX)
102
+ const cursor = decodeCursor(textParam(params, 'cursor'))
103
+
104
+ if (status && !runStatusSchema.safeParse(status).success) throw invalid(`Unknown run status ${status}.`)
105
+ const projects = listProjects(env.root)
106
+ const project = projectKey ? projects.find((entry) => entry.id === projectKey || entry.slug === projectKey || entry.name.toLowerCase() === projectKey.toLowerCase()) : null
107
+ if (projectKey && !project) throw invalid(`There is no project called ${projectKey}.`)
108
+
109
+ const matching = readAllRuns(env.root)
110
+ .filter((run) => !hidden(env, context, run))
111
+ .filter((run) => !agent || run.agentId === agent || (run.agentId !== null && env.directory.agentName(run.agentId)?.toLowerCase() === agent.toLowerCase()))
112
+ .filter((run) => !projectKey || run.projectId === project?.id)
113
+ .filter((run) => !mode || run.mode === mode)
114
+ .filter((run) => !status || run.status === status)
115
+ .filter((run) => !since || Date.parse(run.startedAt) >= since.timestamp)
116
+ .filter((run) => !until || Date.parse(run.startedAt) <= until.timestamp)
117
+ .filter((run) => !cursor || afterCursor(run, cursor))
118
+ .sort((left, right) => compareText(right.startedAt, left.startedAt) || compareText(right.id, left.id))
119
+
120
+ const page = matching.slice(0, limit)
121
+ const hasMore = matching.length > page.length
122
+ return JSON.stringify({
123
+ runs: page.map((run) => summary(env, run, projects)),
124
+ hasMore,
125
+ nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null
126
+ })
127
+ }
128
+
129
+ function boundedValue(value: unknown, path: string, truncated: string[], stringLimit: number, arrayLimit: number, depth = 0): unknown {
130
+ if (typeof value === 'string') {
131
+ const fieldLimit = path.endsWith('.systemPrompt') || path.endsWith('.contextUpdate') ? Math.min(12_000, stringLimit) :
132
+ path.endsWith('.input') || path.endsWith('.output') || path.endsWith('.summary') ? Math.min(10_000, stringLimit) : stringLimit
133
+ if (value.length <= fieldLimit) return value
134
+ truncated.push(path)
135
+ return `${value.slice(0, Math.max(0, fieldLimit - 16))}… [truncated]`
136
+ }
137
+ if (Array.isArray(value)) {
138
+ const kept = value.slice(0, arrayLimit).map((item, index) => boundedValue(item, `${path}[${index}]`, truncated, stringLimit, arrayLimit, depth + 1))
139
+ if (value.length > arrayLimit) truncated.push(path)
140
+ return kept
141
+ }
142
+ if (!value || typeof value !== 'object' || depth >= 8) return value
143
+ const entries = Object.entries(value as Record<string, unknown>)
144
+ const kept = entries.slice(0, 40).map(([key, item]) => [key, boundedValue(item, `${path}.${key}`, truncated, stringLimit, arrayLimit, depth + 1)])
145
+ if (entries.length > 40) truncated.push(path)
146
+ return Object.fromEntries(kept)
147
+ }
148
+
149
+ function publicEvent(event: RunEvent): Record<string, unknown> {
150
+ let stringLimit = 12_000
151
+ let arrayLimit = 20
152
+ while (true) {
153
+ const truncated: string[] = []
154
+ const omitted: string[] = []
155
+ let payload = scrubSensitive(event.payload)
156
+ if (event.type === 'account.status' && payload && typeof payload === 'object' && !Array.isArray(payload)) {
157
+ const account = payload as Record<string, unknown>
158
+ if (Object.hasOwn(account, 'accountId')) {
159
+ delete account.accountId
160
+ omitted.push('payload.accountId')
161
+ }
162
+ if (typeof account.message === 'string') account.message = scrub(account.message)
163
+ }
164
+ if (event.type === 'usage.updated' && payload && typeof payload === 'object' && !Array.isArray(payload)) {
165
+ const request = (payload as Record<string, unknown>).request
166
+ if (request && typeof request === 'object' && !Array.isArray(request) && Object.hasOwn(request, 'reasoningSummary')) {
167
+ delete (request as Record<string, unknown>).reasoningSummary
168
+ omitted.push('payload.request.reasoningSummary')
169
+ }
170
+ }
171
+ const value: Record<string, unknown> = {
172
+ id: event.id,
173
+ sequence: event.sequence,
174
+ timestamp: event.timestamp,
175
+ type: event.type,
176
+ payload: boundedValue(payload, 'payload', truncated, stringLimit, arrayLimit)
177
+ }
178
+ if (event.coalescedFrom !== undefined) value.coalescedFrom = event.coalescedFrom
179
+ if (truncated.length) value.truncatedFields = truncated.slice(0, 40)
180
+ if (omitted.length) value.omittedFields = omitted
181
+ if (JSON.stringify(value).length <= EVENT_MAX_CHARS || (stringLimit <= 500 && arrayLimit <= 1)) return value
182
+ stringLimit = Math.max(500, Math.floor(stringLimit / 2))
183
+ arrayLimit = Math.max(1, Math.floor(arrayLimit / 2))
184
+ }
185
+ }
186
+
187
+ /** `trajectory.read`: a bounded page of the events that affected a run. */
188
+ export function readTool(env: HarnessEnv, context: CallerContext, params: Params): string {
189
+ const requested = textParam(params, 'run')
190
+ if (!requested) throw invalid('Give a run id or "current".')
191
+ const runId = requested === 'current' ? context.runId ?? '' : requested
192
+ if (!runId) throw invalid('This caller has no current run.')
193
+ const run = findRun(env.root, runId)
194
+ if (!run || hidden(env, context, run)) throw notFound('Run')
195
+
196
+ const afterSequence = boundedInteger(params, 'afterSequence', -1, -1, Number.MAX_SAFE_INTEGER)
197
+ const limit = boundedInteger(params, 'limit', READ_DEFAULT, 1, READ_MAX)
198
+ const captured = snapshot(env.root, run.id)
199
+ const candidates = captured.events.filter((event) => event.sequence > afterSequence)
200
+ const events: Record<string, unknown>[] = []
201
+ let eventChars = 0
202
+ for (const event of candidates.slice(0, limit)) {
203
+ const bounded = publicEvent(event)
204
+ const size = JSON.stringify(bounded).length
205
+ if (events.length && eventChars + size > PAGE_MAX_CHARS) break
206
+ events.push(bounded)
207
+ eventChars += size
208
+ }
209
+ const lastSequence = events.length ? Number(events[events.length - 1].sequence) : afterSequence
210
+ const hasMore = candidates.some((event) => event.sequence > lastSequence)
211
+ const projects = listProjects(env.root)
212
+ const result = {
213
+ run: summary(env, captured.run, projects),
214
+ active: isActiveStatus(captured.run.status),
215
+ events,
216
+ hasMore,
217
+ nextAfterSequence: lastSequence
218
+ }
219
+ return JSON.stringify(result)
220
+ }
@@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs'
6
6
  import { join } from 'node:path'
7
7
 
8
8
  import { noteChange } from '../core/changes'
9
+ import { logLine } from '../core/bus'
9
10
  import { HarnessError, notFound } from '../core/errors'
10
11
  import * as frontmatter from '../core/frontmatter'
11
12
  import { atomicWrite, now } from '../core/fs'
@@ -216,7 +217,7 @@ export function searchEntries(root: string, query: string, options: SearchOption
216
217
  try {
217
218
  ranked = wikiIndex.search(root, query, limit * 4, boost)
218
219
  } catch (error) {
219
- process.stderr.write(`[index] search fell back to files: ${error instanceof Error ? error.message : String(error)}\n`)
220
+ logLine(`[index] search fell back to files: ${error instanceof Error ? error.message : String(error)}`)
220
221
  const needle = query.trim().toLowerCase()
221
222
  if (!needle) return []
222
223
  const rank = (entry: IndexedEntry): number => (entry.namespace === boost ? 0 : 2) + (entry.title.toLowerCase().includes(needle) ? 0 : 1)
@@ -3,6 +3,7 @@
3
3
  import { existsSync } from 'node:fs'
4
4
  import type { DatabaseSync } from 'node:sqlite'
5
5
 
6
+ import { logLine } from '../core/bus'
6
7
  import { all, dbPath, deleteIndex, ftsQuery, run, transaction, withIndex } from '../core/index'
7
8
  import { readAllRuns, syncRuns, upsertRun } from '../sessions/runIndex'
8
9
  import { scan } from './wiki'
@@ -55,7 +56,7 @@ export function reindexQuietly(root: string): void {
55
56
  try {
56
57
  reindexWiki(root)
57
58
  } catch (error) {
58
- process.stderr.write(`[index] reindex failed: ${error instanceof Error ? error.message : String(error)}\n`)
59
+ logLine(`[index] reindex failed: ${error instanceof Error ? error.message : String(error)}`)
59
60
  }
60
61
  }
61
62
 
@@ -81,7 +82,7 @@ export function ensure(root: string, after?: () => void): number {
81
82
  try {
82
83
  syncRuns(root)
83
84
  } catch (error) {
84
- process.stderr.write(`[index] ${error instanceof Error ? error.message : String(error)}\n`)
85
+ logLine(`[index] ${error instanceof Error ? error.message : String(error)}`)
85
86
  }
86
87
  } else {
87
88
  pages = rebuild(root)
@@ -2,8 +2,9 @@
2
2
  // exists; none of it is loaded through Pi's extension system.
3
3
  import { CONNECTION_TOOLS, NATIVE_GROUP } from '../prompt/compile'
4
4
  import { ASK_USER } from '../tools/askUser'
5
+ import { SPAWN_SUBAGENT, WAIT_SUBAGENTS } from '../sessions/subagents'
5
6
  import { PLUGIN_PROPOSE } from '../tools/pluginPropose'
6
- import { MEMORY_TOOLS, RECALL_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
7
+ import { MEMORY_TOOLS, RECALL_TOOLS, TRAJECTORY_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
7
8
  import { FIRST_PARTY_PLUGINS } from './firstParty'
8
9
  import type { PluginDescriptor } from './types'
9
10
 
@@ -36,6 +37,7 @@ export const INCLUDED_PLUGIN: PluginDescriptor = { id: 'included', title: 'Inclu
36
37
 
37
38
  export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
38
39
  PI_PLUGIN,
40
+ { id: 'subagents', title: 'Subagents', description: 'Isolated child runs of the same caller, bounded by parent capabilities and nesting depth.', required: 'Availability is selected by the session host; child runs retain the parent permission boundary.', tools: [{ name: SPAWN_SUBAGENT, description: 'Start a subagent' }, { name: WAIT_SUBAGENTS, description: 'Wait for subagents' }] },
39
41
  {
40
42
  id: 'memory',
41
43
  title: 'Memory',
@@ -59,7 +61,7 @@ export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
59
61
  ],
60
62
  toolGroups: ['Knowledge']
61
63
  },
62
- { id: 'history', title: 'History', description: 'Search over earlier conversations and runs.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools(RECALL_TOOLS) },
64
+ { id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]) },
63
65
  {
64
66
  id: 'plugin-proposals',
65
67
  title: 'Plugin proposals',
@@ -73,7 +73,7 @@ describe('the plugin list', () => {
73
73
  ['app-tasks', 'application', ['tool:start_task:on']],
74
74
  ['web', 'application', ['tool:web_search:on', 'tool:web_fetch:on']]
75
75
  ])
76
- expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'history', 'memory', 'output-trim', 'plugin-proposals', 'wiki'])
76
+ expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'history', 'memory', 'output-trim', 'plugin-proposals', 'subagents', 'wiki'])
77
77
 
78
78
  const kit = plugins.find((plugin) => plugin.id === 'package:review-kit')!
79
79
  // It was there when trust was first asked per package, so it was taken over as trusted.
@@ -33,6 +33,7 @@ import { comparison, compile, CONTEXT_PROFILES, load, profiles, render, setProfi
33
33
  import { selectHistory } from '../sessions/history'
34
34
  import { runsSince } from '../sessions/runIndex'
35
35
  import { findRun, listRuns, loadRun, pendingDecisions, readEvents, runFamily, snapshot } from '../sessions/runStore'
36
+ import type { SubagentInput, SubagentSelection } from '../sessions/subagents'
36
37
  import type { SessionRunInput } from '../sessions/sessions'
37
38
  import * as registry from '../skills/registry'
38
39
  import type { TerminalPrepareInput } from '../terminal/terminals'
@@ -82,6 +83,8 @@ export function methods(harness: Harness): Record<string, Handler> {
82
83
  'hub.list': (params: Params) => harness.hub.list(params.refresh === true),
83
84
  'hub.get': (params: Params) => harness.hub.get(text(params, 'id'), text(params, 'version'), params.preview === true),
84
85
  'hub.install': (params: Params) => harness.hub.install(text(params, 'id'), text(params, 'version')),
86
+ 'hub.preview': (params: Params) => harness.hub.preview(text(params, 'id'), text(params, 'version'), typeof params.offset === 'number' ? params.offset : 0),
87
+ 'hub.uninstall': (params: Params) => harness.hub.uninstall(text(params, 'id'), text(params, 'version')),
85
88
  'hub.readDocument': (params: Params) => harness.hub.readDocument(text(params, 'id'), text(params, 'version')),
86
89
 
87
90
  // ---- Accounts --------------------------------------------------------------------------------
@@ -134,6 +137,7 @@ export function methods(harness: Harness): Record<string, Handler> {
134
137
  'connections.setAgentScope': (params: Params) => apps.setAgentScope(params.creator ?? {}, text(params, 'agentId'), Array.isArray(params.ids) ? params.ids : []),
135
138
  'connections.removeAgentScope': (params: Params) => apps.removeAgentScope(text(params, 'agentId')),
136
139
  'connections.revision': () => apps.connections.revision(),
140
+ 'connections.appClients': () => apps.appClients(),
137
141
  'connections.validateScope': (params: Params) => validateScopeIds(apps.connections.list(), { agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') }, Array.isArray(params.ids) ? params.ids : []),
138
142
 
139
143
  // ---- Wiki: what a person browses and edits ---------------------------------------------------
@@ -336,6 +340,10 @@ export function methods(harness: Harness): Record<string, Handler> {
336
340
  },
337
341
 
338
342
  // ---- Sessions ----------------------------------------------------------------------------------
343
+ 'subagents.spawn': (params: Params) => harness.subagents.spawn(params as SubagentInput),
344
+ 'subagents.wait': (params: Params) => harness.subagents.wait(params as SubagentSelection),
345
+ 'subagents.results': (params: Params) => harness.subagents.results(params as SubagentSelection),
346
+ 'sessions.spawnSubagent': (params: Params) => sessions.spawnSubagent(params as SubagentInput),
339
347
  'sessions.run': (params: Params) => sessions.run(params as SessionRunInput),
340
348
  'sessions.history': (params: Params) => ({ sessionFile: selectHistory({ sessionDir: text(params, 'sessionDir'), resume: params.resume ?? null }) }),
341
349
 
@@ -59,6 +59,23 @@ function host(hostCalls: Record<string, (params: any) => unknown> = {}) {
59
59
  const idle = (): PiSessionLike => ({ sessionId: 'native-1', subscribe: () => () => undefined, prompt: async () => undefined, sendCustomMessage: async () => undefined, abort: async () => undefined, dispose: () => undefined })
60
60
 
61
61
  describe('the harness served to a host', () => {
62
+ it('starts native subagents over RPC using the live parent launch', async () => {
63
+ const { transport, request } = host()
64
+ const harness = createHarness({
65
+ dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(),
66
+ sessionFactory: async ({ params }) => ({ ...idle(), ...(params.runId === 'rpc-parent' ? { prompt: async () => new Promise<void>(() => undefined) } : {}) })
67
+ })
68
+ open.push(harness)
69
+ serve(harness, transport)
70
+ harness.accounts.create('Main', 'apiKey')
71
+ await request('sessions.run', { run: { id: 'rpc-parent', sessionId: 'opaque-session' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'sessions'), text: 'Parent', caller: { agentId: 'caller' }, tools: { allow: ['read'] }, subagents: {} })
72
+ const child = await request('subagents.spawn', { parentRunId: 'rpc-parent', brief: 'Review', tools: ['read'] })
73
+ expect(await request('subagents.wait', { parentRunId: 'rpc-parent', runIds: [child.runId] })).toMatchObject([{ runId: child.runId, status: 'completed' }])
74
+ const { loadRun } = await import('../sessions/runStore')
75
+ expect(loadRun(harness.env.root, child.runId)).toMatchObject({ parentRunId: 'rpc-parent', relation: 'subagent', agentId: 'caller', status: 'completed' })
76
+ await request('runs.interrupt', { runId: 'rpc-parent' })
77
+ })
78
+
62
79
  it('answers by method name, reports failures with their code, and forwards events and notices', async () => {
63
80
  const { transport, request, events, notices } = host()
64
81
  const harness = createHarness({ dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), sessionFactory: async () => idle() })
@@ -67,7 +84,9 @@ describe('the harness served to a host', () => {
67
84
 
68
85
  const status = await request('harness.status')
69
86
  expect(status).toMatchObject({ dataRoot: harness.env.root, productName: 'Xingular Harness', activeRuns: [] })
70
- expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'wiki.pages', 'terminal.prepare', 'skills.list', 'skills.import', 'connections.list', 'harness.shutdown']))
87
+ expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'sessions.spawnSubagent', 'subagents.spawn', 'subagents.wait', 'subagents.results', 'wiki.pages', 'terminal.prepare', 'skills.list', 'skills.import', 'connections.list', 'harness.shutdown']))
88
+
89
+ await expect(request('sessions.spawnSubagent', { parentRunId: 'missing', brief: 'Review' })).rejects.toMatchObject({ code: 'run_not_active' })
71
90
 
72
91
  const overview = await request('accounts.create', { label: 'Main', authKind: 'apiKey' })
73
92
  expect(overview.accounts).toHaveLength(1)
@@ -0,0 +1,49 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { Bus } from '../core/bus'
7
+ import { Approvals, grant } from './approvals'
8
+
9
+ const folders: string[] = []
10
+ afterEach(() => { for (const folder of folders.splice(0)) rmSync(folder, { recursive: true, force: true }) })
11
+
12
+ function state() {
13
+ const folder = mkdtempSync(join(tmpdir(), 'harness-approvals-'))
14
+ folders.push(folder)
15
+ const bus = new Bus()
16
+ return { folder, approvals: new Approvals(folder, bus) }
17
+ }
18
+
19
+ describe('standing approval policy', () => {
20
+ it('loads old files with no global tool grants', () => {
21
+ const { folder, approvals } = state()
22
+ writeFileSync(join(folder, 'approvals.json'), JSON.stringify({ auto: { pi: true }, remembered: { old: ['write'] } }))
23
+
24
+ expect(approvals.read()).toEqual({ auto: { pi: true }, globalTools: {}, remembered: { old: ['write'] } })
25
+ })
26
+
27
+ it('persists a profile-wide tool grant, while read-only and other profiles stay isolated', () => {
28
+ const { folder, approvals } = state()
29
+ approvals.allowTool('pi', 'bash')
30
+ const policy = approvals.read()
31
+
32
+ expect(policy.globalTools).toEqual({ pi: ['bash'] })
33
+ expect(grant(policy, 'pi', 'terminal-one', 'bash')).toBe('remembered')
34
+ expect(grant(policy, 'pi', 'terminal-two', 'bash')).toBe('remembered')
35
+ expect(grant(policy, 'chat', 'terminal-two', 'bash')).toBeNull()
36
+ expect(grant(policy, 'pi', 'terminal-two', 'bash', { readOnly: true })).toBeNull()
37
+ expect(JSON.parse(readFileSync(join(folder, 'approvals.json'), 'utf8'))).toMatchObject({ globalTools: { pi: ['bash'] } })
38
+ })
39
+
40
+ it('revokes a global tool grant without changing the full switch or session grants', () => {
41
+ const { approvals } = state()
42
+ approvals.setAuto('pi', true)
43
+ approvals.remember('one-session', 'write')
44
+ approvals.allowTool('pi', 'bash')
45
+
46
+ expect(approvals.revokeTool('pi', 'bash')).toEqual({ auto: { pi: true }, globalTools: {}, remembered: { 'one-session': ['write'] } })
47
+ expect(approvals.read().globalTools).toEqual({})
48
+ })
49
+ })
@@ -1,5 +1,5 @@
1
- // What the user has chosen to stop being asked about: an "always approve" switch per kind of
2
- // session and, per session, the tools they answered "Always allow" for.
1
+ // What the user has chosen to stop being asked about: an "always approve" switch, tools allowed
2
+ // across a harness state directory, and tools remembered for one session.
3
3
  import { chmodSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
 
@@ -14,6 +14,8 @@ export const APPROVAL_KEYS = ['chat', 'pi'] as const
14
14
  export interface ApprovalPolicy {
15
15
  /** Switch key → every approval is granted without asking. */
16
16
  auto: Record<string, boolean>
17
+ /** Approval key → tools allowed across sessions that share this local state directory. */
18
+ globalTools: Record<string, string[]>
17
19
  /** Session id → approval targets always allowed there. */
18
20
  remembered: Record<string, string[]>
19
21
  }
@@ -34,13 +36,14 @@ export interface ApprovalFacts {
34
36
  /** Why the standing choices grant this request, if they do. */
35
37
  export function decide(policy: ApprovalPolicy, key: string, sessionId: string, target: string): AutomaticReason | null {
36
38
  if (policy.auto[key]) return 'always'
39
+ if (policy.globalTools[key]?.includes(target)) return 'remembered'
37
40
  return policy.remembered[sessionId]?.includes(target) ? 'remembered' : null
38
41
  }
39
42
 
40
43
  export function grant(policy: ApprovalPolicy, key: string, sessionId: string, target: string, facts: ApprovalFacts = {}): AutomaticReason | null {
41
44
  if (facts.unattended) return null
42
45
  // "Always approve" must not let a read-only session approve its way out of read-only mode.
43
- return decide(facts.readOnly ? { auto: {}, remembered: policy.remembered } : policy, key, sessionId, target)
46
+ return decide(facts.readOnly ? { auto: {}, globalTools: {}, remembered: policy.remembered } : policy, key, sessionId, target)
44
47
  }
45
48
 
46
49
  /** A conversation's runs share one switch whatever model they use; other runs follow their own. */
@@ -55,7 +58,7 @@ export class Approvals {
55
58
 
56
59
  read(): ApprovalPolicy {
57
60
  const stored = readJson<Partial<ApprovalPolicy>>(this.path) ?? {}
58
- return { auto: stored.auto ?? {}, remembered: stored.remembered ?? {} }
61
+ return { auto: stored.auto ?? {}, globalTools: stored.globalTools ?? {}, remembered: stored.remembered ?? {} }
59
62
  }
60
63
 
61
64
  private change(update: (policy: ApprovalPolicy) => void): ApprovalPolicy {
@@ -90,6 +93,25 @@ export class Approvals {
90
93
  return this.change((policy) => void (policy.auto[key] = on))
91
94
  }
92
95
 
96
+ /** Always allow `target` for every session in this approval profile and state directory. */
97
+ allowTool(key: string, target: string): ApprovalPolicy {
98
+ if (!(APPROVAL_KEYS as readonly string[]).includes(key)) throw invalid('Unknown harness.')
99
+ if (!target.trim()) throw invalid('A tool is required.')
100
+ return this.change((policy) => {
101
+ const targets = (policy.globalTools[key] ??= [])
102
+ if (!targets.includes(target)) targets.push(target)
103
+ })
104
+ }
105
+
106
+ revokeTool(key: string, target: string): ApprovalPolicy {
107
+ if (!(APPROVAL_KEYS as readonly string[]).includes(key)) throw invalid('Unknown harness.')
108
+ return this.change((policy) => {
109
+ const targets = policy.globalTools[key]?.filter((item) => item !== target) ?? []
110
+ if (targets.length) policy.globalTools[key] = targets
111
+ else delete policy.globalTools[key]
112
+ })
113
+ }
114
+
93
115
  /** Always allow `target` in this session. */
94
116
  remember(sessionId: string, target: string): ApprovalPolicy {
95
117
  if (!target.trim()) throw invalid('A tool is required.')
@@ -56,6 +56,62 @@ async function setup() {
56
56
  }
57
57
 
58
58
  describe('a whole reply, with a scripted model', () => {
59
+ it('joins async native children and persists one result receipt before the final reply', async () => {
60
+ const { harness, root, model, start, until, settled, of } = await setup()
61
+ const route = (context: { messages: Array<{ role: string; content: unknown }> }) => {
62
+ const users = context.messages.filter(message => message.role === 'user').map(message => typeof message.content === 'string' ? message.content : (message.content as Array<{ text?: string }>).map(part => part.text ?? '').join(''))
63
+ if (users.includes('native-child-brief')) return model.says('CHILD_EVIDENCE')
64
+ if (JSON.stringify(context.messages).includes('CHILD_EVIDENCE')) return model.says('Final with CHILD_EVIDENCE')
65
+ if (!context.messages.some(message => message.role === 'toolResult')) return model.calls(['spawn_subagent', { brief: 'native-child-brief' }])
66
+ return model.says('Draft before joining')
67
+ }
68
+ model.script(Array(8).fill(route))
69
+ const id = await start()
70
+ await until(() => settled(id))
71
+ expect(of(id).at(-1)?.type).toBe('completed')
72
+ expect(of(id).filter(event => event.type === 'message.completed').at(-1)?.payload).toMatchObject({ text: 'Final with CHILD_EVIDENCE' })
73
+ const { sessionFile } = await import('../prompt/compile')
74
+ const { readFileSync } = await import('node:fs')
75
+ const entries = readFileSync(sessionFile(join(root, 'sessions', 'a1'))!, 'utf8').trim().split('\n').map(line => JSON.parse(line))
76
+ expect(entries.filter(entry => entry.customType === 'xingular-subagent-results')).toHaveLength(1)
77
+ expect(model.requests()).toBeLessThanOrEqual(4)
78
+ expect(harness.runs.activeRunIds()).toEqual([])
79
+ })
80
+
81
+ it('uses blocking native results without adding an unsolicited duplicate delivery', async () => {
82
+ const { root, model, start, until, settled, of } = await setup()
83
+ model.script([model.calls(['spawn_subagent', { brief: 'Find proof', wait: true }]), model.says('INLINE_CHILD_PROOF'), model.says('Final')])
84
+ const id = await start()
85
+ await until(() => settled(id))
86
+ expect(of(id).at(-1)?.type).toBe('completed')
87
+ expect(model.lastToolResults().join('')).toContain('INLINE_CHILD_PROOF')
88
+ const { sessionFile } = await import('../prompt/compile')
89
+ const { readFileSync } = await import('node:fs')
90
+ expect(readFileSync(sessionFile(join(root, 'sessions', 'a1'))!, 'utf8')).not.toContain('"customType":"xingular-subagent-results"')
91
+ expect(model.requests()).toBe(3)
92
+ })
93
+
94
+ it('interrupts a parent waiting on a child question without deadlocking SDK abort', async () => {
95
+ const { harness, model, start, until, settled } = await setup()
96
+ const ai = await import('@earendil-works/pi-ai')
97
+ model.script([
98
+ model.calls(['spawn_subagent', { brief: 'Ask for a decision', wait: true }]),
99
+ ai.fauxAssistantMessage([ai.fauxToolCall('ask_user', { questions: [{ summary: 'A decision', question: 'Which approach?' }] })], { stopReason: 'toolUse' })
100
+ ])
101
+ let childRunId: string | null = null
102
+ harness.runs.listen((event, run) => { if (event.type === 'question.requested' && run?.relation === 'subagent') childRunId = event.runId })
103
+ const id = await start()
104
+ await until(() => childRunId !== null || settled(id))
105
+ expect(childRunId).not.toBeNull()
106
+ await harness.runs.interrupt(id)
107
+ const { loadRun } = await import('./runStore')
108
+ expect(loadRun(harness.env.root, id).status).toBe('interrupted')
109
+ expect(loadRun(harness.env.root, childRunId!).status).toBe('interrupted')
110
+ const { readEvents } = await import('./runStore')
111
+ expect(readEvents(harness.env.root, childRunId!).some(event => event.type === 'question.answered')).toBe(true)
112
+ expect(harness.runs.activeRunIds()).toEqual([])
113
+ })
114
+
59
115
  it('dispatches a registered command with notes without making a provider request', async () => {
60
116
  const { harness, model, start, until, settled, of } = await setup()
61
117
  const account = harness.accounts.context(harness.accounts.resolve(null, null))
@@ -16,7 +16,7 @@ function directChildren(root: string, runId: string): RunSummary[] {
16
16
  try {
17
17
  return runFamily(root, runId).runs
18
18
  .map(({ run }) => run)
19
- .filter((run) => run.parentRunId === runId)
19
+ .filter((run) => run.parentRunId === runId && run.relation !== 'subagent')
20
20
  .map((run) => ({ id: run.id, status: run.status, goal: run.goal, terminalReason: run.terminalReason }))
21
21
  } catch {
22
22
  return []
@@ -0,0 +1,40 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY } from '../prompt/compile'
7
+ import { PLAN_INSTRUCTIONS, PLAN_SECTION_ID, planModeTools, planRefusal, planSection } from './planMode'
8
+
9
+ function history(sectionIds: string[]): string {
10
+ const file = join(mkdtempSync(join(tmpdir(), 'plan-history-')), 'session.jsonl')
11
+ const snapshot = sectionIds.map((id) => ({ id, title: id, body: 'x', sources: [], budget: 100, size: 1, cut: false }))
12
+ writeFileSync(file, [
13
+ JSON.stringify({ type: 'session', id: 'session' }),
14
+ JSON.stringify({ type: 'custom_message', id: 'c1', parentId: null, customType: CONTEXT_MESSAGE_TYPE, details: { [CONTEXT_SNAPSHOT_KEY]: snapshot } })
15
+ ].join('\n'))
16
+ return file
17
+ }
18
+
19
+ describe('plan mode', () => {
20
+ it('keeps read-only inspection, questions and recall, plus provider tools marked read-only', () => {
21
+ const normal = ['read', 'bash', 'edit', 'write', 'grep', 'ask_user', 'wiki_search', 'memory_append', 'spawn_subagent', 'files_read', 'files_write', 'skill_load']
22
+ expect(planModeTools(normal, ['files_read'])).toEqual(['read', 'grep', 'ask_user', 'wiki_search', 'files_read', 'skill_load'])
23
+ })
24
+
25
+ it('refuses changes, commands, memory writes and delegation with a reason the model can act on', () => {
26
+ const readOnly = new Set(['files_read'])
27
+ for (const tool of ['read', 'grep', 'wiki_read', 'files_read', 'ask_user']) expect(planRefusal(tool, readOnly), tool).toBeNull()
28
+ for (const tool of ['bash', 'write', 'edit', 'memory_append', 'wiki_propose', 'spawn_subagent', 'mcp_call_tool', 'files_write']) {
29
+ expect(planRefusal(tool, readOnly), tool).toContain('Plan mode is read-only')
30
+ }
31
+ })
32
+
33
+ it('adds its instructions while on, withdraws them once after, and is absent otherwise', () => {
34
+ expect(planSection(true, null)).toMatchObject({ id: PLAN_SECTION_ID, body: PLAN_INSTRUCTIONS, dynamic: true })
35
+ expect(PLAN_INSTRUCTIONS).toContain('<proposed_plan>')
36
+ expect(planSection(false, null)).toBeNull()
37
+ expect(planSection(false, history(['blocks']))).toBeNull()
38
+ expect(planSection(false, history(['blocks', PLAN_SECTION_ID]))).toMatchObject({ id: PLAN_SECTION_ID, body: null, dynamic: true })
39
+ })
40
+ })