pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.10
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/.pantheon/code-mode/manifest.json +21 -0
- package/CHANGELOG.md +1026 -0
- package/README.md +171 -16
- package/README.pt-BR.md +99 -13
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +23 -14
- package/plugin.json +1 -1
- package/pyproject.toml +65 -0
- package/scripts/code_mode_server.py +431 -55
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +178 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +137 -35
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +537 -128
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +121 -53
- package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +537 -128
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +43 -1
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +345 -62
- package/src/pantheon/delegate-manager.ts +390 -8
- package/src/pantheon/delegation-finalize.ts +32 -0
- package/src/pantheon/delegation.ts +235 -18
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +146 -12
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +18 -1
- package/src/plugin-v2.ts +2 -14
- package/src/plugin.ts +28 -17
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +116 -27
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +414 -150
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +447 -84
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +447 -84
- package/.pantheon/code-mode/.gitkeep +0 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
- package/src/pantheon/delegate-task-adapter.ts +0 -444
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
|
|
62
62
|
import { createPantheonLogger } from './logger.ts'
|
|
63
63
|
import { buildAgentListDescription } from './permission-globs.ts'
|
|
64
|
+
import { safeSessionPath } from './session-guard.ts'
|
|
64
65
|
import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
|
|
65
66
|
|
|
66
67
|
export type {
|
|
@@ -96,6 +97,40 @@ function readIntegerEnv(
|
|
|
96
97
|
return Number.isSafeInteger(value) && value >= minimum ? value : fallback
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// ─── B1: unresolved session template guard ─────────────────────────────
|
|
101
|
+
//
|
|
102
|
+
// The TUI/runtime can hand a plugin surface the UNSUBSTITUTED template literal
|
|
103
|
+
// `{sessionID}` (URL-encoded `%7BsessionID%7D`) when no session is focused.
|
|
104
|
+
// Forwarding it to a session API makes the opencode server reject every call
|
|
105
|
+
// (`Expected a string starting with "ses", got "%7BsessionID%7D"`), which the
|
|
106
|
+
// beta.7 E2E recorded at ~1k errors/s. The delegation layer is a defensive
|
|
107
|
+
// choke point too: it is downstream of the plugin adapter in production, but
|
|
108
|
+
// tests, older hosts, and direct embedders can inject a raw client.
|
|
109
|
+
//
|
|
110
|
+
// This guard targets the placeholder ONLY. It is deliberately NOT the full
|
|
111
|
+
// `isValidSessionId` contract: structural test hosts use non-"ses" ids on
|
|
112
|
+
// purpose (delegation-bootstrap.test.ts), and rejecting those would be a
|
|
113
|
+
// behavior change unrelated to this bug. `session-guard.ts` remains the
|
|
114
|
+
// strict choke point for the plugin adapters.
|
|
115
|
+
const UNRESOLVED_SESSION_TEMPLATE = /(?:\{sessionID\}|%7BsessionID%7D)/i
|
|
116
|
+
|
|
117
|
+
/** True when a session id is an unresolved `{sessionID}` template placeholder. */
|
|
118
|
+
export function isUnresolvedSessionTemplate(id: unknown): boolean {
|
|
119
|
+
return typeof id === 'string' && UNRESOLVED_SESSION_TEMPLATE.test(id)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Refuse a placeholder session id before it reaches a session API. Returns
|
|
124
|
+
* true (and logs a clear warning) when the caller must skip the operation.
|
|
125
|
+
*/
|
|
126
|
+
function refuseUnresolvedSessionTemplate(id: unknown, operation: string): boolean {
|
|
127
|
+
if (!isUnresolvedSessionTemplate(id)) return false
|
|
128
|
+
log.warn(
|
|
129
|
+
`[pantheon-delegate] ${operation} skipped: unresolved session template "${String(id)}" — the host did not substitute the session id`,
|
|
130
|
+
)
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
99
134
|
type BootstrapOutcome =
|
|
100
135
|
| { status: 'started'; elapsedMs: number; reason: string }
|
|
101
136
|
| { status: 'startup_failed' | 'bootstrap_unknown'; elapsedMs: number; reason: string }
|
|
@@ -115,6 +150,9 @@ async function promptWithTimeout(
|
|
|
115
150
|
input: Parameters<DelegationClient['session']['promptAsync']>[0],
|
|
116
151
|
timeoutMs: number,
|
|
117
152
|
): Promise<PromptOutcome> {
|
|
153
|
+
if (refuseUnresolvedSessionTemplate(input.path.id, 'promptAsync')) {
|
|
154
|
+
return { status: 'rejected', reason: `invalid sessionID: ${input.path.id}` }
|
|
155
|
+
}
|
|
118
156
|
const controller = new AbortController()
|
|
119
157
|
let timer: NodeJS.Timeout | undefined
|
|
120
158
|
const operation = client.session.promptAsync({ ...input, signal: controller.signal }).then(
|
|
@@ -190,6 +228,14 @@ async function bootstrapChild(
|
|
|
190
228
|
const hasStatusAPI = typeof client.session.get === 'function'
|
|
191
229
|
const acceptedRunning = isRunningSession(accepted)
|
|
192
230
|
|
|
231
|
+
if (refuseUnresolvedSessionTemplate(childSessionID, 'bootstrap probe')) {
|
|
232
|
+
return {
|
|
233
|
+
status: 'bootstrap_unknown',
|
|
234
|
+
elapsedMs: 0,
|
|
235
|
+
reason: `invalid child sessionID "${childSessionID}"`,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
193
239
|
if (!hasMessagesAPI && !hasStatusAPI) {
|
|
194
240
|
return acceptedRunning
|
|
195
241
|
? {
|
|
@@ -403,6 +449,72 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
|
403
449
|
}
|
|
404
450
|
}
|
|
405
451
|
|
|
452
|
+
// ─── B2: session-scoped delegation target resolution ───────────────────
|
|
453
|
+
//
|
|
454
|
+
// Board aliases restart per parent session (apo-1 in every root), so an alias
|
|
455
|
+
// lookup MUST be scoped to the calling session. `pantheon_delegation_read`
|
|
456
|
+
// previously delegated to the board's first-match resolver and, when the id
|
|
457
|
+
// was not found in the current session, the generic "Unknown delegation"
|
|
458
|
+
// message hid the real cause (the alias belonged to another session). This
|
|
459
|
+
// helper makes the scoping explicit and returns a CLEAR error for each
|
|
460
|
+
// cross-session / ambiguous case.
|
|
461
|
+
|
|
462
|
+
/** Resolution outcome for a delegation read by task ID or alias. */
|
|
463
|
+
export type DelegationTargetResolution =
|
|
464
|
+
| { kind: 'found'; job: BackgroundJobRecord }
|
|
465
|
+
| { kind: 'error'; message: string }
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Resolve a `pantheon_delegation_read` target with strict session scoping:
|
|
469
|
+
* - exact task-ID match first (must belong to `sessionID`);
|
|
470
|
+
* - then alias matches WITHIN `sessionID` (exactly one required);
|
|
471
|
+
* - an alias that exists only in another session → clear cross-session
|
|
472
|
+
* error (never another session's job);
|
|
473
|
+
* - two matches in this session → clear ambiguity error.
|
|
474
|
+
*/
|
|
475
|
+
export function resolveDelegationTarget(
|
|
476
|
+
board: Pick<BackgroundJobBoard, 'list'>,
|
|
477
|
+
sessionID: string,
|
|
478
|
+
idOrAlias: string,
|
|
479
|
+
): DelegationTargetResolution {
|
|
480
|
+
const jobs = board.list()
|
|
481
|
+
|
|
482
|
+
const byTaskID = jobs.find((job) => job.taskID === idOrAlias)
|
|
483
|
+
if (byTaskID !== undefined) {
|
|
484
|
+
if (byTaskID.parentSessionID === sessionID) return { kind: 'found', job: byTaskID }
|
|
485
|
+
return {
|
|
486
|
+
kind: 'error',
|
|
487
|
+
message:
|
|
488
|
+
`Delegation "${idOrAlias}" belongs to a different session ` +
|
|
489
|
+
`(${byTaskID.parentSessionID}) and cannot be read from ${sessionID}.`,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const aliasMatches = jobs.filter((job) => job.alias === idOrAlias)
|
|
494
|
+
const inSession = aliasMatches.filter((job) => job.parentSessionID === sessionID)
|
|
495
|
+
if (inSession.length === 1) return { kind: 'found', job: inSession[0]! }
|
|
496
|
+
if (inSession.length > 1) {
|
|
497
|
+
return {
|
|
498
|
+
kind: 'error',
|
|
499
|
+
message:
|
|
500
|
+
`Delegation alias "${idOrAlias}" is ambiguous in session ${sessionID} ` +
|
|
501
|
+
`(${inSession.length} matches); read it by task ID instead.`,
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (aliasMatches.length > 0) {
|
|
505
|
+
return {
|
|
506
|
+
kind: 'error',
|
|
507
|
+
message:
|
|
508
|
+
`Delegation alias "${idOrAlias}" belongs to a different session — ` +
|
|
509
|
+
'aliases are session-scoped. Use pantheon_delegation_list in the session that created it.',
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
kind: 'error',
|
|
514
|
+
message: `Unknown delegation "${idOrAlias}" for this session. Use pantheon_delegation_list to see active delegations.`,
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
406
518
|
/** Normalize the SDK-provided caller agent without inventing an identity. */
|
|
407
519
|
const normalizeToolAgent = normalizeDelegationAgent
|
|
408
520
|
|
|
@@ -509,6 +621,7 @@ async function sampleChildActivity(
|
|
|
509
621
|
collector: ActivityCollector,
|
|
510
622
|
): Promise<void> {
|
|
511
623
|
if (typeof client.session.messages !== 'function') return
|
|
624
|
+
if (refuseUnresolvedSessionTemplate(childSessionID, 'activity sample')) return
|
|
512
625
|
try {
|
|
513
626
|
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
514
627
|
if (!Array.isArray(bundles)) return
|
|
@@ -534,6 +647,7 @@ async function lastChildActivity(
|
|
|
534
647
|
childSessionID: string,
|
|
535
648
|
): Promise<string | undefined> {
|
|
536
649
|
if (typeof client.session.messages !== 'function') return undefined
|
|
650
|
+
if (refuseUnresolvedSessionTemplate(childSessionID, 'last activity fetch')) return undefined
|
|
537
651
|
try {
|
|
538
652
|
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
539
653
|
if (!Array.isArray(bundles)) return undefined
|
|
@@ -740,10 +854,59 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
740
854
|
return finalizeDelegationReport(deps, childSessionID, opts)
|
|
741
855
|
}
|
|
742
856
|
|
|
857
|
+
// Kill-switch gate: when delegationEnabled is explicitly false (mirrors
|
|
858
|
+
// PANTHEON_DELEGATION=off from the native manager), all legacy tools throw.
|
|
859
|
+
function guardEnabled(): void {
|
|
860
|
+
if (options.delegationEnabled === false) {
|
|
861
|
+
throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Best-effort teardown of a child session whose `registerLaunch` was refused
|
|
867
|
+
* because the per-agent concurrency limit was hit between the pre-check and
|
|
868
|
+
* the registration (a race with another concurrent dispatch). Removes every
|
|
869
|
+
* side effect of the aborted dispatch — known-child marker, read-only
|
|
870
|
+
* registry entry and any reserved budget slot — then deletes the session when
|
|
871
|
+
* the host client exposes `session.delete`. Never throws: cleanup must not
|
|
872
|
+
* mask the original rejection.
|
|
873
|
+
*/
|
|
874
|
+
async function abortRejectedLaunch(
|
|
875
|
+
childSessionID: string,
|
|
876
|
+
wasReadOnly: boolean,
|
|
877
|
+
refund?: { key: string | undefined; reserved: boolean },
|
|
878
|
+
): Promise<void> {
|
|
879
|
+
knownChildren.delete(childSessionID)
|
|
880
|
+
if (wasReadOnly) readOnlyRegistry.unregister(childSessionID)
|
|
881
|
+
if (refund?.reserved && refund.key !== undefined) {
|
|
882
|
+
budgetUsage.set(refund.key, Math.max(0, (budgetUsage.get(refund.key) ?? 1) - 1))
|
|
883
|
+
}
|
|
884
|
+
// The plugin's adapted client (adaptDelegationClient) does not expose
|
|
885
|
+
// session.delete, and older hosts may not either. Delete best-effort so the
|
|
886
|
+
// abandoned session is not left behind; the registry cleanup above already
|
|
887
|
+
// removes the side effects that matter for read-only enforcement.
|
|
888
|
+
const session = client.session as {
|
|
889
|
+
delete?: (input: { path: { id: string } }) => Promise<unknown>
|
|
890
|
+
}
|
|
891
|
+
if (typeof session.delete !== 'function') return
|
|
892
|
+
const sessionPath = safeSessionPath(childSessionID)
|
|
893
|
+
if (sessionPath === null) {
|
|
894
|
+
log.warn(`[pantheon-delegate] session.delete skipped: invalid sessionID "${childSessionID}"`)
|
|
895
|
+
return
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
await session.delete(sessionPath)
|
|
899
|
+
} catch (err: unknown) {
|
|
900
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
901
|
+
log.warn(`[pantheon-delegate] failed to delete orphan session ${childSessionID}: ${reason}`)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
743
905
|
const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
|
|
744
906
|
description: buildDelegateDescription(options),
|
|
745
907
|
args: delegateArgs,
|
|
746
908
|
execute: async (args, ctx) => {
|
|
909
|
+
guardEnabled()
|
|
747
910
|
if (!isRootSession(ctx.sessionID)) {
|
|
748
911
|
throw new Error(
|
|
749
912
|
`pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
|
|
@@ -855,6 +1018,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
855
1018
|
return `pantheon_delegate failed to create child session: ${reason}`
|
|
856
1019
|
}
|
|
857
1020
|
const childSessionID = created.id
|
|
1021
|
+
// B1: a host that passes through the unresolved template must never
|
|
1022
|
+
// produce a board job / bootstrap poll — reject it here with a clear
|
|
1023
|
+
// message and refund the reserved budget (no child was started).
|
|
1024
|
+
if (isUnresolvedSessionTemplate(childSessionID)) {
|
|
1025
|
+
if (budgetReserved && budgetKey !== undefined) {
|
|
1026
|
+
budgetUsage.set(budgetKey, Math.max(0, (budgetUsage.get(budgetKey) ?? 1) - 1))
|
|
1027
|
+
}
|
|
1028
|
+
log.warn(
|
|
1029
|
+
`[pantheon-delegate] session.create returned unresolved session template "${childSessionID}" — child ignored`,
|
|
1030
|
+
)
|
|
1031
|
+
return `pantheon_delegate failed: host returned an unresolved child session template ("${childSessionID}"); expected an id starting with "ses".`
|
|
1032
|
+
}
|
|
858
1033
|
knownChildren.add(childSessionID)
|
|
859
1034
|
options.registerChildSession?.(childSessionID, ctx.sessionID)
|
|
860
1035
|
|
|
@@ -871,13 +1046,27 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
871
1046
|
readOnlyRegistry.register(childSessionID, entry)
|
|
872
1047
|
}
|
|
873
1048
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1049
|
+
let job: BackgroundJobRecord
|
|
1050
|
+
try {
|
|
1051
|
+
job = await board.registerLaunch({
|
|
1052
|
+
taskID: childSessionID,
|
|
1053
|
+
parentSessionID: ctx.sessionID,
|
|
1054
|
+
agent: args.agent,
|
|
1055
|
+
description: args.description ?? effectivePrompt,
|
|
1056
|
+
objective: effectivePrompt,
|
|
1057
|
+
})
|
|
1058
|
+
} catch (err: unknown) {
|
|
1059
|
+
// Concurrency race: another dispatch claimed the last slot after the
|
|
1060
|
+
// pre-check above but before this registration. Undo every side effect
|
|
1061
|
+
// of the aborted dispatch so no orphan session/registry survives, then
|
|
1062
|
+
// surface a clear rejection.
|
|
1063
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
1064
|
+
await abortRejectedLaunch(childSessionID, readOnly, {
|
|
1065
|
+
key: budgetKey,
|
|
1066
|
+
reserved: budgetReserved,
|
|
1067
|
+
})
|
|
1068
|
+
return `pantheon_delegate rejected: ${reason}`
|
|
1069
|
+
}
|
|
881
1070
|
|
|
882
1071
|
// Timeout manager: cleared on finalize, unref'd so the timer never
|
|
883
1072
|
// keeps the process alive on its own.
|
|
@@ -966,15 +1155,42 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
966
1155
|
return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
|
|
967
1156
|
}
|
|
968
1157
|
const retryID = retry.id
|
|
1158
|
+
if (isUnresolvedSessionTemplate(retryID)) {
|
|
1159
|
+
log.warn(
|
|
1160
|
+
`[pantheon-delegate] retry session.create returned unresolved session template "${retryID}" — retry ignored`,
|
|
1161
|
+
)
|
|
1162
|
+
return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; host returned an unresolved retry session template ("${retryID}")`
|
|
1163
|
+
}
|
|
969
1164
|
knownChildren.add(retryID)
|
|
970
1165
|
options.registerChildSession?.(retryID, ctx.sessionID)
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1166
|
+
|
|
1167
|
+
// Phase 4 read-only enforcement for the retry: recalculate readOnly
|
|
1168
|
+
// (same logic as the initial child at :866-872) and register the retry
|
|
1169
|
+
// session so the plugin's tool.execute.before guard enforces it.
|
|
1170
|
+
const retryReadOnly =
|
|
1171
|
+
args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
|
|
1172
|
+
if (retryReadOnly) {
|
|
1173
|
+
const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
|
|
1174
|
+
if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
|
|
1175
|
+
readOnlyRegistry.register(retryID, entry)
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
let retryJob: BackgroundJobRecord
|
|
1179
|
+
try {
|
|
1180
|
+
retryJob = await board.registerLaunch({
|
|
1181
|
+
taskID: retryID,
|
|
1182
|
+
parentSessionID: ctx.sessionID,
|
|
1183
|
+
agent: args.agent,
|
|
1184
|
+
description: args.description ?? effectivePrompt,
|
|
1185
|
+
objective: effectivePrompt,
|
|
1186
|
+
})
|
|
1187
|
+
} catch (err: unknown) {
|
|
1188
|
+
// Same race as the initial child: the retry registration can be
|
|
1189
|
+
// refused. Clean up the retry session + registry entry before failing.
|
|
1190
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
1191
|
+
await abortRejectedLaunch(retryID, retryReadOnly)
|
|
1192
|
+
return `pantheon_delegate rejected: ${reason} (retry)`
|
|
1193
|
+
}
|
|
978
1194
|
const retryTimer = setTimeout(() => {
|
|
979
1195
|
timers.delete(retryID)
|
|
980
1196
|
void finalize(retryID, {
|
|
@@ -1035,10 +1251,10 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
1035
1251
|
'Resolves by alias or task ID.',
|
|
1036
1252
|
args: readArgs,
|
|
1037
1253
|
execute: async (args, ctx) => {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1254
|
+
guardEnabled()
|
|
1255
|
+
const resolution = resolveDelegationTarget(board, ctx.sessionID, args.id)
|
|
1256
|
+
if (resolution.kind === 'error') return resolution.message
|
|
1257
|
+
const job = resolution.job
|
|
1042
1258
|
|
|
1043
1259
|
// Sample the child's messages while waiting so the caller sees what the
|
|
1044
1260
|
// agent is doing — fail-open: no messages support → report as before.
|
|
@@ -1093,6 +1309,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
1093
1309
|
'List background delegations for the current session, with [unread] for finished jobs.',
|
|
1094
1310
|
args: listArgs,
|
|
1095
1311
|
execute: async (_args, ctx) => {
|
|
1312
|
+
guardEnabled()
|
|
1096
1313
|
const jobs = board.list(ctx.sessionID)
|
|
1097
1314
|
if (jobs.length === 0) return 'No background delegations for this session.'
|
|
1098
1315
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort salvage of complete top-level JSON objects from a corrupt
|
|
3
|
+
* `state.json`. The file is normally a JSON array; a torn write can leave a
|
|
4
|
+
* truncated tail (or interleaved NUL bytes). We scan for balanced `{…}`
|
|
5
|
+
* objects, parsing each independently, so every intact record survives.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from file-persistence.ts to keep that module within the 300-line
|
|
8
|
+
* ceiling. Exported for tests.
|
|
9
|
+
*/
|
|
10
|
+
import type { BackgroundJobRecord } from './background-job-board.ts'
|
|
11
|
+
|
|
12
|
+
export function salvageRecords(content: string): BackgroundJobRecord[] {
|
|
13
|
+
const records: BackgroundJobRecord[] = []
|
|
14
|
+
let depth = 0
|
|
15
|
+
let start = -1
|
|
16
|
+
let inString = false
|
|
17
|
+
let escaped = false
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < content.length; i++) {
|
|
20
|
+
const ch = content[i]
|
|
21
|
+
|
|
22
|
+
if (inString) {
|
|
23
|
+
if (escaped) {
|
|
24
|
+
escaped = false
|
|
25
|
+
} else if (ch === '\\') {
|
|
26
|
+
escaped = true
|
|
27
|
+
} else if (ch === '"') {
|
|
28
|
+
inString = false
|
|
29
|
+
}
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (ch === '"') {
|
|
34
|
+
inString = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (ch === '{') {
|
|
38
|
+
if (depth === 0) start = i
|
|
39
|
+
depth++
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (ch === '}') {
|
|
43
|
+
if (depth > 0) depth--
|
|
44
|
+
if (depth === 0 && start >= 0) {
|
|
45
|
+
const candidate = content.slice(start, i + 1)
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(candidate)
|
|
48
|
+
if (
|
|
49
|
+
parsed !== null &&
|
|
50
|
+
typeof parsed === 'object' &&
|
|
51
|
+
typeof (parsed as BackgroundJobRecord).taskID === 'string'
|
|
52
|
+
) {
|
|
53
|
+
records.push(parsed as BackgroundJobRecord)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Incomplete/partial object — skip it, keep scanning.
|
|
57
|
+
}
|
|
58
|
+
start = -1
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return records
|
|
64
|
+
}
|