@raidou/pi-pm-subagents 0.1.1
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/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextUsage,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
Theme,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent'
|
|
6
|
+
import type { TUI } from '@earendil-works/pi-tui'
|
|
7
|
+
import { Editor } from '@earendil-works/pi-tui'
|
|
8
|
+
import {
|
|
9
|
+
isKeyRelease,
|
|
10
|
+
Key,
|
|
11
|
+
matchesKey,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
} from '@earendil-works/pi-tui'
|
|
14
|
+
import { orderBy } from 'lodash-es'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
formatContextUsage,
|
|
18
|
+
formatElapsed,
|
|
19
|
+
rightAlign,
|
|
20
|
+
strInline,
|
|
21
|
+
} from '../utils/format.js'
|
|
22
|
+
import { truncateText } from '../utils/truncate.js'
|
|
23
|
+
import { FOLLOW_SYMBOL } from './consts.ts'
|
|
24
|
+
|
|
25
|
+
const FLEET_KEY = 'pi-pm-subagents:fleet'
|
|
26
|
+
const TICK_MS = 200
|
|
27
|
+
const MAX_ROWS = 8
|
|
28
|
+
|
|
29
|
+
function visibleWindow(
|
|
30
|
+
rowCount: number,
|
|
31
|
+
selectedRowIndex: number,
|
|
32
|
+
): { start: number; end: number; hiddenAbove: number; hiddenBelow: number } {
|
|
33
|
+
const visible = Math.min(MAX_ROWS, rowCount)
|
|
34
|
+
const start = Math.max(0, selectedRowIndex - visible + 1)
|
|
35
|
+
const end = Math.min(start + visible, rowCount)
|
|
36
|
+
return { start, end, hiddenAbove: start, hiddenBelow: rowCount - end }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type FleetEntryStatus = 'running' | 'done' | 'failed' | 'killed'
|
|
40
|
+
|
|
41
|
+
export interface FleetEntryBase {
|
|
42
|
+
title: string
|
|
43
|
+
status: FleetEntryStatus
|
|
44
|
+
followUpCount: number
|
|
45
|
+
startedAt: number
|
|
46
|
+
completedAt?: number
|
|
47
|
+
contextUsage?: ContextUsage
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FleetEntry extends FleetEntryBase {
|
|
51
|
+
id: number
|
|
52
|
+
previousEntries: FleetEntryBase[]
|
|
53
|
+
role: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FleetListOptions {
|
|
57
|
+
list: () => FleetEntry[]
|
|
58
|
+
onOpen: (ctx: ExtensionContext, id: number) => void | Promise<void>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type FleetRow = {
|
|
62
|
+
kind: 'item' | 'previous'
|
|
63
|
+
entry: FleetEntryBase
|
|
64
|
+
item: FleetEntry
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class FleetList {
|
|
68
|
+
private ctx: ExtensionContext | undefined
|
|
69
|
+
private tui: TUI | undefined
|
|
70
|
+
private inputUnsub: (() => void) | undefined
|
|
71
|
+
private timer: ReturnType<typeof setInterval> | undefined
|
|
72
|
+
private registered = false
|
|
73
|
+
private activeSelect = false
|
|
74
|
+
private selectedIndex = 0
|
|
75
|
+
|
|
76
|
+
constructor(private options: FleetListOptions) {}
|
|
77
|
+
|
|
78
|
+
setContext(ctx: ExtensionContext): void {
|
|
79
|
+
if (ctx === this.ctx) return
|
|
80
|
+
this.inputUnsub?.()
|
|
81
|
+
this.ctx = ctx
|
|
82
|
+
this.registered = false
|
|
83
|
+
this.tui = undefined
|
|
84
|
+
this.inputUnsub = ctx.ui.onTerminalInput((data) => this.handleKey(data))
|
|
85
|
+
this.update()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
update(): void {
|
|
89
|
+
const ctx = this.ctx
|
|
90
|
+
if (!ctx) return
|
|
91
|
+
|
|
92
|
+
const items = this.options.list()
|
|
93
|
+
if (items.length === 0) {
|
|
94
|
+
this.hide()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.clampSelection()
|
|
99
|
+
this.ensureTimer()
|
|
100
|
+
|
|
101
|
+
if (!this.registered) {
|
|
102
|
+
ctx.ui.setWidget(
|
|
103
|
+
FLEET_KEY,
|
|
104
|
+
(tui) => {
|
|
105
|
+
this.tui = tui
|
|
106
|
+
return {
|
|
107
|
+
render: (width: number) => this.renderBar(width),
|
|
108
|
+
invalidate: () => {
|
|
109
|
+
this.registered = false
|
|
110
|
+
this.tui = undefined
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{ placement: 'belowEditor' },
|
|
115
|
+
)
|
|
116
|
+
this.registered = true
|
|
117
|
+
} else {
|
|
118
|
+
this.tui?.requestRender()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
hide() {
|
|
123
|
+
const ctx = this.ctx
|
|
124
|
+
if (!ctx) return
|
|
125
|
+
if (this.registered) {
|
|
126
|
+
ctx.ui.setWidget(FLEET_KEY, undefined)
|
|
127
|
+
this.registered = false
|
|
128
|
+
this.tui = undefined
|
|
129
|
+
}
|
|
130
|
+
this.stopTimer()
|
|
131
|
+
this.activeSelect = false
|
|
132
|
+
this.selectedIndex = 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private editorHasFocus(): boolean {
|
|
136
|
+
if (!this.tui) return true
|
|
137
|
+
const focused = (this.tui as unknown as { focusedComponent?: unknown })
|
|
138
|
+
.focusedComponent
|
|
139
|
+
if (focused == null) return true
|
|
140
|
+
return focused instanceof Editor
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
dispose(): void {
|
|
144
|
+
this.stopTimer()
|
|
145
|
+
this.inputUnsub?.()
|
|
146
|
+
this.inputUnsub = undefined
|
|
147
|
+
this.hide()
|
|
148
|
+
this.ctx = undefined
|
|
149
|
+
this.activeSelect = false
|
|
150
|
+
this.selectedIndex = 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private roster(): FleetRow[] {
|
|
154
|
+
const items = orderBy(
|
|
155
|
+
this.options.list(),
|
|
156
|
+
[(it) => (it.status === 'running' ? 0 : 1), (it) => it.id],
|
|
157
|
+
['asc', 'desc'],
|
|
158
|
+
)
|
|
159
|
+
const rows: FleetRow[] = []
|
|
160
|
+
for (const item of items) {
|
|
161
|
+
rows.push({ kind: 'item', entry: item, item })
|
|
162
|
+
for (const previous of item.previousEntries) {
|
|
163
|
+
rows.push({ kind: 'previous', entry: previous, item })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return rows
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private clampSelection(): void {
|
|
170
|
+
const max = this.roster().length
|
|
171
|
+
const min = this.activeSelect ? 1 : 0
|
|
172
|
+
this.selectedIndex = Math.max(min, Math.min(this.selectedIndex, max))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private handleKey(data: string): { consume?: boolean } | undefined {
|
|
176
|
+
const ctx = this.ctx
|
|
177
|
+
if (!ctx) return undefined
|
|
178
|
+
if (isKeyRelease(data)) return undefined
|
|
179
|
+
if (this.tui?.hasOverlay()) return
|
|
180
|
+
if (!this.editorHasFocus()) {
|
|
181
|
+
if (this.activeSelect) {
|
|
182
|
+
this.deactivate()
|
|
183
|
+
}
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!this.activeSelect) {
|
|
188
|
+
const activator = matchesKey(data, 'down') || matchesKey(data, 'left')
|
|
189
|
+
if (
|
|
190
|
+
activator &&
|
|
191
|
+
this.options.list().length > 0 &&
|
|
192
|
+
ctx.ui.getEditorText() === ''
|
|
193
|
+
) {
|
|
194
|
+
this.activeSelect = true
|
|
195
|
+
this.selectedIndex = 1
|
|
196
|
+
this.update()
|
|
197
|
+
return { consume: true }
|
|
198
|
+
}
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (matchesKey(data, 'down')) {
|
|
203
|
+
const max = this.roster().length
|
|
204
|
+
this.selectedIndex = Math.min(max, this.selectedIndex + 1)
|
|
205
|
+
this.update()
|
|
206
|
+
return { consume: true }
|
|
207
|
+
}
|
|
208
|
+
if (matchesKey(data, 'up')) {
|
|
209
|
+
if (this.selectedIndex === 1) {
|
|
210
|
+
this.deactivate()
|
|
211
|
+
return { consume: true }
|
|
212
|
+
}
|
|
213
|
+
this.selectedIndex -= 1
|
|
214
|
+
this.update()
|
|
215
|
+
return { consume: true }
|
|
216
|
+
}
|
|
217
|
+
if (matchesKey(data, Key.escape)) {
|
|
218
|
+
this.deactivate()
|
|
219
|
+
return { consume: true }
|
|
220
|
+
}
|
|
221
|
+
if (matchesKey(data, Key.enter)) {
|
|
222
|
+
void this.openSelected()
|
|
223
|
+
return { consume: true }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.deactivate()
|
|
227
|
+
return undefined
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private deactivate(): void {
|
|
231
|
+
this.activeSelect = false
|
|
232
|
+
this.selectedIndex = 0
|
|
233
|
+
this.update()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async openSelected(): Promise<void> {
|
|
237
|
+
const ctx = this.ctx
|
|
238
|
+
if (!ctx) return
|
|
239
|
+
const entry = this.roster()[this.selectedIndex - 1]
|
|
240
|
+
if (!entry) {
|
|
241
|
+
this.deactivate()
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
const id = entry.item.id
|
|
245
|
+
await this.options.onOpen(ctx, id)
|
|
246
|
+
this.update()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private renderBar(width: number): string[] {
|
|
250
|
+
const ctx = this.ctx
|
|
251
|
+
if (!ctx) return []
|
|
252
|
+
const theme = ctx.ui.theme
|
|
253
|
+
const rows = this.roster()
|
|
254
|
+
if (rows.length === 0) return []
|
|
255
|
+
|
|
256
|
+
const sel = this.activeSelect ? this.selectedIndex : -1
|
|
257
|
+
const hint = this.activeSelect
|
|
258
|
+
? '↑↓ select · enter view · esc back'
|
|
259
|
+
: 'esc to interrupt · ←/↓ for items'
|
|
260
|
+
const mainLine = ` ${theme.fg('dim', 'subagents')}`
|
|
261
|
+
const lines: string[] = [
|
|
262
|
+
truncateText(` ${theme.fg('dim', hint)}`, width),
|
|
263
|
+
'',
|
|
264
|
+
truncateText(mainLine, width),
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
const selRow = sel >= 1 && sel <= rows.length ? rows[sel - 1] : undefined
|
|
268
|
+
const selectedParentId =
|
|
269
|
+
selRow?.kind === 'previous' ? selRow.item.id : undefined
|
|
270
|
+
const selectedRow = sel - 1
|
|
271
|
+
const { start, end, hiddenAbove, hiddenBelow } = visibleWindow(
|
|
272
|
+
rows.length,
|
|
273
|
+
selectedRow,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if (hiddenAbove > 0) {
|
|
277
|
+
lines.push(
|
|
278
|
+
rightAlign('', theme.fg('dim', `↑ ${hiddenAbove} more`), width),
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const [offset, row] of rows.slice(start, end).entries()) {
|
|
283
|
+
const rowNumber = start + offset + 1
|
|
284
|
+
const hlBullet = rowNumber === sel || row.item.id === selectedParentId
|
|
285
|
+
const prefix =
|
|
286
|
+
row.kind === 'previous'
|
|
287
|
+
? ` ${theme.fg('muted', '↳')} `
|
|
288
|
+
: ` ${this.bullet(hlBullet, theme)} ${theme.fg('muted', `#${row.item.id} [${row.item.role}]`)} `
|
|
289
|
+
const line = this.renderItemRow(
|
|
290
|
+
row.entry,
|
|
291
|
+
prefix,
|
|
292
|
+
width,
|
|
293
|
+
rowNumber === sel,
|
|
294
|
+
theme,
|
|
295
|
+
)
|
|
296
|
+
lines.push(truncateText(line, width))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (hiddenBelow > 0) {
|
|
300
|
+
lines.push(
|
|
301
|
+
rightAlign('', theme.fg('dim', `↓ ${hiddenBelow} more`), width),
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
return lines
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private renderTitle(
|
|
308
|
+
status: FleetEntryStatus,
|
|
309
|
+
title: string,
|
|
310
|
+
theme: Theme,
|
|
311
|
+
): string {
|
|
312
|
+
switch (status) {
|
|
313
|
+
case 'running':
|
|
314
|
+
return theme.fg('syntaxVariable', theme.bold(title))
|
|
315
|
+
case 'done':
|
|
316
|
+
return title
|
|
317
|
+
case 'failed':
|
|
318
|
+
case 'killed':
|
|
319
|
+
return theme.fg('error', title)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private bullet(highlight: boolean, theme: Theme): string {
|
|
324
|
+
return highlight ? theme.fg('accent', '●') : theme.fg('dim', '◯')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private renderItemRow(
|
|
328
|
+
entry: FleetEntryBase,
|
|
329
|
+
prefix: string,
|
|
330
|
+
width: number,
|
|
331
|
+
isSelected: boolean,
|
|
332
|
+
theme: Theme,
|
|
333
|
+
): string {
|
|
334
|
+
const inlineTitle = strInline(entry.title)
|
|
335
|
+
const processedTitle = this.renderTitle(entry.status, inlineTitle, theme)
|
|
336
|
+
const left = prefix + processedTitle
|
|
337
|
+
const statusCol = theme.fg('accent', entry.status.padStart(7, ' '))
|
|
338
|
+
const followCol = theme.fg(
|
|
339
|
+
'border',
|
|
340
|
+
` ${FOLLOW_SYMBOL} ${entry.followUpCount}`.padStart(3, ' '),
|
|
341
|
+
)
|
|
342
|
+
const elapsedCol = theme.fg('muted', formatElapsed(entry).padStart(8, ' '))
|
|
343
|
+
const contextCol = this.renderContextCol(entry, theme)
|
|
344
|
+
const right = `${statusCol}${contextCol}${followCol}${elapsedCol}`
|
|
345
|
+
const leftMaxWidth = Math.max(0, width - visibleWidth(right) - 1)
|
|
346
|
+
const line = rightAlign(truncateText(left, leftMaxWidth), right, width)
|
|
347
|
+
return isSelected ? theme.bg('selectedBg', line) : line
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private renderContextCol(entry: FleetEntryBase, theme: Theme): string {
|
|
351
|
+
const cu = entry.contextUsage
|
|
352
|
+
if (!cu || cu.contextWindow === 0) return theme.fg('muted', ' '.repeat(12))
|
|
353
|
+
const padded = formatContextUsage(cu).padStart(12, ' ')
|
|
354
|
+
const percent = cu.percent
|
|
355
|
+
const color =
|
|
356
|
+
percent !== null && percent > 90
|
|
357
|
+
? 'error'
|
|
358
|
+
: percent !== null && percent > 70
|
|
359
|
+
? 'warning'
|
|
360
|
+
: 'muted'
|
|
361
|
+
return theme.fg(color, padded)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private ensureTimer(): void {
|
|
365
|
+
if (!this.timer) {
|
|
366
|
+
this.timer = setInterval(() => this.tui?.requestRender(), TICK_MS)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private stopTimer(): void {
|
|
371
|
+
if (this.timer) {
|
|
372
|
+
clearInterval(this.timer)
|
|
373
|
+
this.timer = undefined
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isSubagentSpawnContext,
|
|
5
|
+
runInSubagentSpawnContext,
|
|
6
|
+
SUBAGENT_SESSION_ID_PREFIX,
|
|
7
|
+
} from './identity.js'
|
|
8
|
+
|
|
9
|
+
describe('subagent spawn context', () => {
|
|
10
|
+
it('is false outside of spawn windows', () => {
|
|
11
|
+
expect(isSubagentSpawnContext()).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('keeps the stable session id prefix', () => {
|
|
15
|
+
expect(SUBAGENT_SESSION_ID_PREFIX).toBe('pi-pm-subagents-subagent-')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('is true inside a spawn window', async () => {
|
|
19
|
+
await runInSubagentSpawnContext(7, async () => {
|
|
20
|
+
expect(isSubagentSpawnContext()).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
expect(isSubagentSpawnContext()).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('propagates across await boundaries', async () => {
|
|
26
|
+
await runInSubagentSpawnContext(3, async () => {
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
28
|
+
expect(isSubagentSpawnContext()).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
export const SUBAGENT_SESSION_ID_PREFIX = 'pi-pm-subagents-subagent-'
|
|
4
|
+
|
|
5
|
+
const spawnContext = new AsyncLocalStorage<{ id: number }>()
|
|
6
|
+
|
|
7
|
+
export function isSubagentSpawnContext(): boolean {
|
|
8
|
+
return spawnContext.getStore() !== undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function runInSubagentSpawnContext<T>(
|
|
12
|
+
id: number,
|
|
13
|
+
fn: () => Promise<T>,
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
return spawnContext.run({ id }, fn)
|
|
16
|
+
}
|