pantheon-opencode 1.5.0-beta.7 → 1.5.0-beta.9

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 CHANGED
@@ -18,6 +18,54 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
18
18
 
19
19
  ## ✅ Closed Issues
20
20
 
21
+ ## [v1.5.0-beta.9] - 2026-09-14
22
+
23
+ <!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
24
+ move this section to a versioned entry and reset the template below. -->
25
+
26
+ ## 🆕 What's New
27
+
28
+ - **TUI delegations nativas (gated):** painel Delegations renderiza N delegações native + M pantheon com tag [native], live task em tempo real, click navega para sessão. Implementado em `src/plugins/tui/src/index.tsx` com dist rebuildado.
29
+ - **93 testes TUI delegations:** suite `tests/pantheon/tui-delegations.test.ts` com 93/93 passando (tsc + biome OK).
30
+ - **E2E beta.8 PASS (referência):** baseline E2E da beta.8 mantido como referência de estabilidade para beta.9.
31
+
32
+ ## 🐞 Fixed
33
+
34
+ - Removido diretório `testyes/` (artefato de teste).
35
+ - Truncado `hooks.log` para baseline limpo de beta.9.
36
+
37
+ ## [v1.5.0-beta.8] - 2026-09-13
38
+
39
+ <!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
40
+ move this section to a versioned entry and reset the template below. -->
41
+
42
+ ## 🐞 Fixed
43
+
44
+ - **Board persistence is corruption- and concurrency-safe:** state writes now go
45
+ through a UNIQUE temp file (per-process, per-write counter + random suffix), are
46
+ `fsync`ed, then renamed over the destination with bounded ENOENT retries, and
47
+ read-modify-write cycles on the same state path are serialized. This closes the
48
+ window where two writers could interleave and corrupt `state.json` or race the
49
+ rename.
50
+ - **Corrupt board state is quarantined and salvaged:** a malformed `state.json` is
51
+ moved aside to `<state>.corrupt-<ts>`, a clear error is logged, and every complete
52
+ record that can be parsed is salvaged and returned instead of throwing — so
53
+ delete/prune/recover callers cannot get stuck in a retry loop.
54
+ - **P1-1 — no orphan `.signal.json` on reconcile:** reconcile now deletes any
55
+ still-pending wake signal instead of re-writing it. Reconcile is an
56
+ acknowledgment, not a new terminal transition, so the finalize→reconcile cycle
57
+ no longer leaves a stale `<alias>.signal.json` with `state:"reconciled"` behind.
58
+ - **Unresolved `{sessionID}` template guard:** the TUI/runtime can hand the plugin
59
+ the unsubstituted literal `{sessionID}` (or `%7BsessionID%7D`) when no session is
60
+ focused; the plugin now refuses placeholder session ids at the delegation,
61
+ bootstrap-probe, and activity-sample boundaries instead of forwarding them to the
62
+ session API (which rejected every call).
63
+ - **Delegation lookup scoped to the calling session:** `pantheon_delegation_read`
64
+ resolves exact task IDs first (must belong to the caller's session), then alias
65
+ matches within that session (exactly one required). Cross-session and ambiguous
66
+ aliases now return explicit errors instead of the generic "Unknown delegation".
67
+
68
+
21
69
  ## [v1.5.0-beta.7] - 2026-09-13
22
70
 
23
71
  &lt;!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.5.0-beta.7",
3
+ "version": "1.5.0-beta.9",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon",
3
3
  "publisher": "ils15",
4
- "version": "1.5.0-beta.7",
4
+ "version": "1.5.0-beta.9",
5
5
  "displayName": "Pantheon",
6
6
  "description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
7
7
  "agents": "./src/agents",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
4
4
 
5
5
  [project]
6
6
  name = "pantheon"
7
- version = "1.5.0-beta.7"
7
+ version = "1.5.0-beta.9"
8
8
  description = "Multi-agent orchestration framework"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = []
@@ -457,7 +457,12 @@ export class BackgroundJobBoard {
457
457
  job.terminalUnreconciled = false
458
458
 
459
459
  await this.persistRecord(job)
460
- await this.writeSignal(job)
460
+ // P1-1: reconcile is an acknowledgment, NOT a new terminal transition
461
+ // (onTerminal does not re-fire). Re-writing a `.signal.json` here leaked
462
+ // orphan signals with `state:"reconciled"` after the finalize→reconcile
463
+ // cycle deleted them. Instead, remove any still-pending wake signal so
464
+ // reconcile leaves no `<alias>.signal.json` behind.
465
+ await this.deleteSignal(job.alias)
461
466
 
462
467
  return job
463
468
  }
@@ -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
@@ -775,8 +889,13 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
775
889
  delete?: (input: { path: { id: string } }) => Promise<unknown>
776
890
  }
777
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
+ }
778
897
  try {
779
- await session.delete({ path: { id: childSessionID } })
898
+ await session.delete(sessionPath)
780
899
  } catch (err: unknown) {
781
900
  const reason = err instanceof Error ? err.message : String(err)
782
901
  log.warn(`[pantheon-delegate] failed to delete orphan session ${childSessionID}: ${reason}`)
@@ -899,6 +1018,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
899
1018
  return `pantheon_delegate failed to create child session: ${reason}`
900
1019
  }
901
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
+ }
902
1033
  knownChildren.add(childSessionID)
903
1034
  options.registerChildSession?.(childSessionID, ctx.sessionID)
904
1035
 
@@ -1024,6 +1155,12 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1024
1155
  return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
1025
1156
  }
1026
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
+ }
1027
1164
  knownChildren.add(retryID)
1028
1165
  options.registerChildSession?.(retryID, ctx.sessionID)
1029
1166
 
@@ -1115,10 +1252,9 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1115
1252
  args: readArgs,
1116
1253
  execute: async (args, ctx) => {
1117
1254
  guardEnabled()
1118
- const job = board.resolve(ctx.sessionID, args.id)
1119
- if (!job) {
1120
- return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
1121
- }
1255
+ const resolution = resolveDelegationTarget(board, ctx.sessionID, args.id)
1256
+ if (resolution.kind === 'error') return resolution.message
1257
+ const job = resolution.job
1122
1258
 
1123
1259
  // Sample the child's messages while waiting so the caller sees what the
1124
1260
  // agent is doing — fail-open: no messages support → report as before.
@@ -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
+ }