opencode-swarm 7.138.4 → 7.139.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.
Files changed (91) hide show
  1. package/README.md +7 -6
  2. package/dist/cli/{config-doctor-31yppse4.js → config-doctor-tgnr91q3.js} +2 -2
  3. package/dist/cli/{core-65c7rvqf.js → core-g2dfh9fq.js} +1 -1
  4. package/dist/cli/{curation-policy-hs9pc1rp.js → curation-policy-2qnhgdtt.js} +7 -7
  5. package/dist/cli/{curator-drift-jpap6hv4.js → curator-drift-vfh07mqy.js} +5 -2
  6. package/dist/cli/{curator-llm-factory-wm27ra25.js → curator-llm-factory-1ftzece0.js} +28 -28
  7. package/dist/cli/{curator-54n1y4j8.js → curator-nbsag633.js} +28 -28
  8. package/dist/cli/{evidence-summary-service-hrxcf29a.js → evidence-summary-service-kzxr71yd.js} +9 -9
  9. package/dist/cli/{gate-evidence-ecx4djaw.js → gate-evidence-z2b1ymnw.js} +5 -5
  10. package/dist/cli/{guardrail-explain-1729nse8.js → guardrail-explain-vb9k9zwm.js} +29 -29
  11. package/dist/cli/{guardrail-log-yjj5srkw.js → guardrail-log-0djnvfas.js} +3 -3
  12. package/dist/cli/{hive-promoter-es2x2b0e.js → hive-promoter-bpt26yew.js} +28 -28
  13. package/dist/cli/{index-mycqmsz5.js → index-0wp67gqj.js} +452 -159
  14. package/dist/cli/{index-c60whfgh.js → index-0ymhx85c.js} +2 -2
  15. package/dist/cli/{index-5q009kc4.js → index-15526t16.js} +8 -3
  16. package/dist/cli/{index-smsy2p8n.js → index-3eddtgxt.js} +1 -1
  17. package/dist/cli/{index-4fmztnrq.js → index-4xbyy6aw.js} +2 -2
  18. package/dist/cli/{index-xybtaph7.js → index-5j6chvj8.js} +1 -1
  19. package/dist/cli/{index-39k6y018.js → index-5w7ekpy3.js} +2 -2
  20. package/dist/cli/{index-z7jgvm9b.js → index-5zyk8qgv.js} +2 -2
  21. package/dist/cli/{index-en4fb04r.js → index-668dpw5e.js} +1 -1
  22. package/dist/cli/{index-m8txs2a9.js → index-91s653xx.js} +1 -1
  23. package/dist/cli/{index-y28n2wmq.js → index-a12906mj.js} +1 -1
  24. package/dist/cli/{index-10mz5js6.js → index-a5eba0t8.js} +2 -2
  25. package/dist/cli/{index-57stye65.js → index-aymt7hzw.js} +6 -3
  26. package/dist/cli/{index-1g6xpjk3.js → index-db7wf6ke.js} +7 -7
  27. package/dist/cli/{index-m1pwkwfe.js → index-dw09fz3w.js} +1 -1
  28. package/dist/cli/{index-fsrp8wp3.js → index-ed5ykebp.js} +25 -2
  29. package/dist/cli/{index-jbge0dyh.js → index-en6qq954.js} +3 -3
  30. package/dist/cli/{index-tqshdzaw.js → index-gvhvz176.js} +2 -2
  31. package/dist/cli/{index-e1kgxchq.js → index-hgrkhxbk.js} +2 -2
  32. package/dist/cli/{index-33q2gmgd.js → index-hn0mdcmj.js} +1 -1
  33. package/dist/cli/{index-a9xny52r.js → index-ht4q3548.js} +2 -2
  34. package/dist/cli/{index-q344kj84.js → index-prmb5qfp.js} +2 -2
  35. package/dist/cli/{index-x20cgr2q.js → index-q7ezzgwq.js} +16 -2
  36. package/dist/cli/{index-ngqxzkqm.js → index-s608d3hw.js} +2 -2
  37. package/dist/cli/{index-50mwt8fx.js → index-vxbppwvf.js} +5 -5
  38. package/dist/cli/{index-rrxcfxrd.js → index-wrt6m5ze.js} +1 -1
  39. package/dist/cli/{index-4qzeef9h.js → index-x9q4bbhd.js} +1 -1
  40. package/dist/cli/{index-m40ea3zk.js → index-xec724kg.js} +2 -2
  41. package/dist/cli/{index-k27a4e5g.js → index-xka8s8jw.js} +30 -30
  42. package/dist/cli/{index-a18hk2fw.js → index-xr07r80d.js} +513 -483
  43. package/dist/cli/{index-gm3pjfhb.js → index-y1zcsf2a.js} +1 -1
  44. package/dist/cli/{index-tjwz5tct.js → index-ywysg26s.js} +4 -4
  45. package/dist/cli/index.js +28 -28
  46. package/dist/cli/{knowledge-escalator-s6z1x65b.js → knowledge-escalator-pjg2x75c.js} +8 -8
  47. package/dist/cli/{knowledge-events-nnyn6hst.js → knowledge-events-vxheptk8.js} +6 -6
  48. package/dist/cli/{knowledge-link-xc6yhxmz.js → knowledge-link-3tq8w6gs.js} +5 -5
  49. package/dist/cli/{knowledge-store-x4yfc7v3.js → knowledge-store-36mascwz.js} +6 -6
  50. package/dist/cli/{knowledge-validator-1v047gsa.js → knowledge-validator-anats6rj.js} +9 -9
  51. package/dist/cli/{pending-delegations-7dbc0j2a.js → pending-delegations-tmyf18p4.js} +5 -5
  52. package/dist/cli/{pr-subscriptions-m943zn6g.js → pr-subscriptions-hhx6yk24.js} +5 -5
  53. package/dist/cli/{runner-x1hy047y.js → runner-w30gazew.js} +6 -6
  54. package/dist/cli/{scan-cursor-jxajj6s7.js → scan-cursor-apbz9eez.js} +7 -7
  55. package/dist/cli/{schema-fj32eyae.js → schema-x9fr2ey8.js} +3 -1
  56. package/dist/cli/{scope-persistence-w7zen2wz.js → scope-persistence-3f0d4fwv.js} +4 -4
  57. package/dist/cli/{skill-generator-rt0jqcsa.js → skill-generator-20czn4kd.js} +10 -10
  58. package/dist/cli/{telemetry-0yqed3jv.js → telemetry-4tgrfhaz.js} +1 -1
  59. package/dist/cli/{worktree-collision-ownership-hjrt5hx2.js → worktree-collision-ownership-vexb0b6r.js} +38 -8
  60. package/dist/config/context-window.d.ts +210 -0
  61. package/dist/config/schema.d.ts +18 -0
  62. package/dist/evaluation/ephemeral-agent-dispatcher.d.ts +18 -0
  63. package/dist/evaluation/model-dispatcher.d.ts +1 -0
  64. package/dist/hooks/delegate-ack-collector.d.ts +1 -1
  65. package/dist/hooks/delegation-gate/worktree-collision-ownership.d.ts +7 -0
  66. package/dist/hooks/delegation-gate/worktree-isolation.d.ts +2 -1
  67. package/dist/hooks/delegation-gate/worktree-provisioning-owner.d.ts +6 -0
  68. package/dist/hooks/index.d.ts +2 -2
  69. package/dist/hooks/init-orphan-recovery.d.ts +4 -1
  70. package/dist/hooks/knowledge-application-gate.d.ts +2 -1
  71. package/dist/hooks/knowledge-application.d.ts +5 -4
  72. package/dist/hooks/knowledge-injector.d.ts +16 -0
  73. package/dist/hooks/knowledge-types.d.ts +1 -1
  74. package/dist/hooks/messages-transform.d.ts +18 -0
  75. package/dist/hooks/model-limits.d.ts +88 -30
  76. package/dist/index.js +501 -499
  77. package/dist/memory/injector.d.ts +2 -0
  78. package/dist/plan/manager.d.ts +2 -0
  79. package/dist/services/context-budget-service.d.ts +6 -1
  80. package/dist/services/run-memory.d.ts +53 -5
  81. package/dist/services/status-service.d.ts +14 -0
  82. package/dist/state.d.ts +37 -0
  83. package/dist/tools/context-status.d.ts +3 -1
  84. package/dist/tools/update-task-status.d.ts +2 -0
  85. package/dist/turbo/lean/runner.d.ts +10 -0
  86. package/dist/utils/ephemeral-session-teardown.d.ts +98 -0
  87. package/dist/utils/path-security.d.ts +95 -1
  88. package/dist/utils/swarm-artifact-cache.d.ts +13 -0
  89. package/dist/worktree/index.d.ts +1 -1
  90. package/dist/worktree/merge.d.ts +36 -3
  91. package/package.json +1 -1
@@ -34,9 +34,11 @@ export interface MemoryLifecycleHooks {
34
34
  }
35
35
  export declare function createMemoryLifecycleHooks(options: MemoryLifecycleHookOptions): MemoryLifecycleHooks;
36
36
  declare function messagesContainRecall(messages: unknown[]): boolean;
37
+ declare function recallMessageInsertIndex(messages: unknown[]): number;
37
38
  declare function compactText(text: string): string;
38
39
  export type { ProposeMemoryInput, MemoryKind };
39
40
  export declare const _test_exports: {
40
41
  compactText: typeof compactText;
41
42
  messagesContainRecall: typeof messagesContainRecall;
43
+ recallMessageInsertIndex: typeof recallMessageInsertIndex;
42
44
  };
@@ -41,6 +41,7 @@ export interface AcknowledgedRemovals {
41
41
  import { type Plan, type RuntimePlan, type TaskStatus } from '../config/plan-schema';
42
42
  import { isGitRepo } from '../git/branch';
43
43
  import { getWorktreeMergeFailure } from '../hooks/delegation-gate/worktree-merge-status';
44
+ import { recordTaskAttempt } from '../services/run-memory.js';
44
45
  import { isEpicModeActiveForProject } from '../turbo/epic/state.js';
45
46
  import { commitTaskCompletion } from '../turbo/epic/task-commit.js';
46
47
  import { readTaskScopes } from '../turbo/lean/conflicts.js';
@@ -64,6 +65,7 @@ export declare const _internals: {
64
65
  readTaskScopes: typeof readTaskScopes;
65
66
  commitTaskCompletion: typeof commitTaskCompletion;
66
67
  getWorktreeMergeFailure: typeof getWorktreeMergeFailure;
68
+ recordTaskAttempt: typeof recordTaskAttempt;
67
69
  };
68
70
  /** @internal Test seam for snapshot retry helper */
69
71
  export declare const _snapshot_test_exports: {
@@ -42,7 +42,12 @@ export interface ContextBudgetReport {
42
42
  export interface ContextBudgetConfig {
43
43
  /** Enable or disable budget monitoring */
44
44
  enabled: boolean;
45
- /** Maximum token budget (default: 40000) */
45
+ /**
46
+ * The context-window denominator, in tokens. Production callers derive this
47
+ * per-model via `resolveContextWindowTokens` (`src/config/context-window.ts`)
48
+ * and never pass a constant; `DEFAULT_CONTEXT_BUDGET_CONFIG.budgetTokens`
49
+ * below is only the no-information floor.
50
+ */
46
51
  budgetTokens: number;
47
52
  /** Warning threshold percentage (default: 70) */
48
53
  warningPct: number;
@@ -16,7 +16,28 @@ export interface RunMemoryEntry {
16
16
  taskFingerprint: string;
17
17
  /** Which agent executed the task (e.g. "coder") */
18
18
  agent: string;
19
- /** Outcome of the task execution */
19
+ /**
20
+ * Outcome of the task execution.
21
+ *
22
+ * PRODUCER COVERAGE (keep this note accurate — directive 2, no unwired code):
23
+ * - `pass` / `fail` are produced today by TWO call sites, deliberately split:
24
+ * 1. `plan/manager.updateTaskStatus` records the TERMINAL outcome after
25
+ * savePlan succeeds — `completed` -> pass, `blocked` -> fail. It lives
26
+ * there, not in the `update_task_status` tool, because the tool is only
27
+ * one of two writers: the council APPROVE fast-path completes tasks via
28
+ * `advanceTaskStateAndPersist` (src/state.ts) with no tool call.
29
+ * 2. `update_task_status` records a `fail` when a council or QA gate
30
+ * BLOCKS a completion. That is a refusal, not a status transition, so
31
+ * it never reaches updateTaskStatus and must be recorded at the tool.
32
+ * - `retry` has a CONSUMER (`summarizeTask` below treats it like `fail`) but
33
+ * no producer. It is deliberately retained for the guardrails
34
+ * transient-retry path (AGENTS.md invariant 9), which owns retry
35
+ * accounting and is out of scope here. Do not add a `retry` producer from
36
+ * the task-status path — a gate block is a real failure, not a retry.
37
+ * - `skip` has neither producer nor consumer (`summarizeTask` ignores it).
38
+ * It is retained only so pre-existing `.swarm/run-memory.jsonl` lines
39
+ * written by hand or by older builds still parse.
40
+ */
20
41
  outcome: 'pass' | 'fail' | 'retry' | 'skip';
21
42
  /** 1-indexed attempt number */
22
43
  attemptNumber: number;
@@ -51,12 +72,39 @@ export declare function recordOutcome(directory: string, entry: RunMemoryEntry):
51
72
  */
52
73
  export declare function getTaskHistory(directory: string, taskId: string): Promise<RunMemoryEntry[]>;
53
74
  /**
54
- * Get all failure and retry entries
75
+ * Input for {@link recordTaskAttempt}. `attemptNumber` and `taskFingerprint`
76
+ * are derived, not supplied — callers know the task, not its history.
77
+ */
78
+ export interface TaskAttemptInput {
79
+ /** Plan.json task ID (e.g. "3.2") */
80
+ taskId: string;
81
+ /** Which agent executed the task (e.g. "coder") */
82
+ agent: string;
83
+ /** Outcome of this attempt */
84
+ outcome: RunMemoryEntry['outcome'];
85
+ /** One-line failure reason — required in practice for fail/retry to be useful */
86
+ failureReason?: string;
87
+ /** Plan `files_touched` for the task; feeds the fingerprint */
88
+ fileTargets?: string[];
89
+ /** Wall-clock time in milliseconds */
90
+ durationMs?: number;
91
+ }
92
+ /**
93
+ * Record one task attempt, deriving the attempt number from prior history.
94
+ *
95
+ * This is the single production producer for `.swarm/run-memory.jsonl`, which
96
+ * `getRunMemorySummary` reads back for cross-turn failure-pattern avoidance.
97
+ *
98
+ * Advisory-only and fail-open by contract: run memory is context enrichment,
99
+ * never a correctness gate, so a write failure must never surface to — or
100
+ * abort — the caller's primary operation (a task status update). The work is
101
+ * still awaited rather than fire-and-forget, so the entry is durable before the
102
+ * caller returns and a test can observe it.
55
103
  *
56
104
  * @param directory - The swarm workspace directory
57
- * @returns Array of fail/retry entries
105
+ * @param input - The attempt to record
58
106
  */
59
- export declare function getFailures(directory: string): Promise<RunMemoryEntry[]>;
107
+ export declare function recordTaskAttempt(directory: string, input: TaskAttemptInput): Promise<void>;
60
108
  /**
61
109
  * Group entries by taskId
62
110
  */
@@ -79,8 +127,8 @@ export declare function getRunMemorySummary(directory: string): Promise<string |
79
127
  export declare const _internals: {
80
128
  generateTaskFingerprint: typeof generateTaskFingerprint;
81
129
  recordOutcome: typeof recordOutcome;
130
+ recordTaskAttempt: typeof recordTaskAttempt;
82
131
  getTaskHistory: typeof getTaskHistory;
83
- getFailures: typeof getFailures;
84
132
  getRunMemorySummary: typeof getRunMemorySummary;
85
133
  groupByTaskId: typeof groupByTaskId;
86
134
  summarizeTask: typeof summarizeTask;
@@ -72,6 +72,20 @@ export interface StatusData {
72
72
  leanPauseReason?: string;
73
73
  /** Last known context budget percentage (0-100), or null if not yet measured */
74
74
  contextBudgetPct: number | null;
75
+ /**
76
+ * The DENOMINATOR `contextBudgetPct` was measured against, in tokens, or
77
+ * null when no budget report has run. Carried alongside the percentage
78
+ * because the denominator is now derived per-model (`model.limit.context`)
79
+ * rather than being a constant this renderer could assume. The renderer
80
+ * previously back-computed the estimate from
81
+ * `DEFAULT_CONTEXT_BUDGET_CONFIG.budgetTokens`, which was **40000** (not
82
+ * 128000 — that was the schema default for `model_limits.default`, a
83
+ * different constant on a different path). So the status line printed a
84
+ * figure that did not match the percentage beside it for any user whose
85
+ * warnings fired against anything other than 40000 — i.e. every user with a
86
+ * `model_limits` override, and now everyone, since the live window is used.
87
+ */
88
+ contextBudgetTokens?: number | null;
75
89
  /** Number of context compaction events triggered this session */
76
90
  compactionCount: number;
77
91
  /** ISO timestamp of last compaction snapshot, or null if none */
package/dist/state.d.ts CHANGED
@@ -598,6 +598,24 @@ export declare const swarmState: {
598
598
  generatedAgentNames: string[];
599
599
  /** Last known context budget percentage (0-100), updated by system-enhancer */
600
600
  lastBudgetPct: number;
601
+ /**
602
+ * The DENOMINATOR `lastBudgetPct` was computed against, in tokens. Written
603
+ * by system-enhancer at the same two statements that write `lastBudgetPct`,
604
+ * because a percentage without its denominator cannot be turned back into a
605
+ * token estimate. `/swarm status` used to reconstruct the estimate from a
606
+ * hardcoded constant, so a user whose real denominator differed saw a token
607
+ * figure that did not match the percentage next to it. 0 means "no budget
608
+ * report has run yet".
609
+ */
610
+ lastBudgetTokens: number;
611
+ /**
612
+ * Live `model.limit.context` per session — keyed by sessionID, recorded by
613
+ * the `experimental.chat.system.transform` hook (the only hook the host
614
+ * gives a `Model` to) and read by the `experimental.chat.messages.transform`
615
+ * consumers, which receive messages but no model object. Bounded via
616
+ * {@link setLiveContextWindow} (AGENTS.md invariant 8).
617
+ */
618
+ liveContextWindows: Map<string, number>;
601
619
  /** Per-session guardrail state — keyed by sessionID */
602
620
  agentSessions: Map<string, AgentSessionState>;
603
621
  /** In-flight rehydration promises — awaited by rehydrateState before clearing agentSessions */
@@ -1142,6 +1160,25 @@ export declare function ensureSessionEnvironment(sessionId: string): Environment
1142
1160
  export declare const MAX_TRACKED_CRITICAL_SHOWN = 500;
1143
1161
  export declare const MAX_TRACKED_KNOWLEDGE_ACKS = 5000;
1144
1162
  export declare const MAX_TRACKED_GATE_DENIALS = 500;
1163
+ export declare const MAX_TRACKED_CONTEXT_WINDOWS = 500;
1164
+ /**
1165
+ * Record the live `model.limit.context` the host reported for `sessionID`,
1166
+ * FIFO-evicting the oldest entry past {@link MAX_TRACKED_CONTEXT_WINDOWS}.
1167
+ *
1168
+ * Only accepts a finite number ≥ 1 — the caller (`src/config/context-window.ts`
1169
+ * `isUsableContextWindow`) applies the real plausibility floor, and storing a
1170
+ * junk value here would hand it to every `messages.transform` consumer. A
1171
+ * rejected value leaves any previously recorded window in place rather than
1172
+ * clobbering it, so one malformed turn does not blank a good reading.
1173
+ */
1174
+ export declare function setLiveContextWindow(sessionID: string | undefined, tokens: unknown): void;
1175
+ /**
1176
+ * Read the live context window recorded for `sessionID`, or `undefined` when
1177
+ * no `system.transform` has run for it yet (first turn of a session, or a
1178
+ * consumer that never sees a sessionID). Callers must degrade to the static
1179
+ * resolution rungs rather than assume a value.
1180
+ */
1181
+ export declare function getLiveContextWindow(sessionID: string | undefined): number | undefined;
1145
1182
  /** Set the critical shown ids for a session, FIFO-evicting the oldest entry
1146
1183
  * if the cap is exceeded. Re-setting an existing key keeps insertion order
1147
1184
  * fresh for that key (delete-then-set). */
@@ -79,9 +79,11 @@ export declare const _internals: {
79
79
  * @param warnThreshold - Usage ratio that triggers 'warn' state (default 0.7)
80
80
  * @param criticalThreshold - Usage ratio that triggers 'critical' state (default 0.9)
81
81
  * @param modelLimitsConfig - Model-specific limit overrides from config
82
+ * @param liveContextLimit - Live `model.limit.context` recorded for the session
83
+ * by the system.transform hook; `undefined` when none has been seen yet
82
84
  * @returns Context status with token usage, limit, and threshold state
83
85
  */
84
- declare function computeContextHeadroom(messages: ContextMessage[], warnThreshold?: number, criticalThreshold?: number, modelLimitsConfig?: Record<string, number>): ContextStatusResult;
86
+ declare function computeContextHeadroom(messages: ContextMessage[], warnThreshold?: number, criticalThreshold?: number, modelLimitsConfig?: Record<string, number>, liveContextLimit?: unknown): ContextStatusResult;
85
87
  /**
86
88
  * context_status tool — read-only context window headroom report.
87
89
  *
@@ -6,6 +6,7 @@ import type { ToolContext, ToolDefinition } from '@opencode-ai/plugin/tool';
6
6
  import { readTaskEvidenceRaw } from '../gate-evidence.js';
7
7
  import { tryAcquireLock } from '../parallel/file-locks.js';
8
8
  import { loadPlan, updateTaskStatus } from '../plan/manager';
9
+ import { recordTaskAttempt } from '../services/run-memory.js';
9
10
  import { hasActiveLeanTurbo, hasActiveTurboMode } from '../state';
10
11
  import { type ReviewerGateEvidenceKind, type ReviewerGateReasonCode } from '../telemetry.js';
11
12
  import { verifyLeanTurboTaskCompletion } from '../turbo/lean/task-completion';
@@ -26,6 +27,7 @@ export declare const _internals: {
26
27
  verifyLeanTurboTaskCompletion: typeof verifyLeanTurboTaskCompletion;
27
28
  hasPassedDurableGateEvidence: typeof hasPassedDurableGateEvidence;
28
29
  emitReviewerGateDecision: (sessionId: string, taskId: string, blocked: boolean, reasonCode: ReviewerGateReasonCode, evidenceKind: ReviewerGateEvidenceKind) => void;
30
+ recordTaskAttempt: typeof recordTaskAttempt;
29
31
  };
30
32
  /**
31
33
  * Arguments for the update_task_status tool
@@ -81,6 +81,16 @@ interface SessionClient {
81
81
  id: string;
82
82
  };
83
83
  }): Promise<void>;
84
+ /**
85
+ * Optional graceful abort. Present on the real opencode SDK session at
86
+ * runtime (absent on minimal test fakes). Teardown awaits it before delete
87
+ * so opencode flushes the final part/message (#2123).
88
+ */
89
+ abort?(options: {
90
+ path: {
91
+ id: string;
92
+ };
93
+ }): Promise<unknown>;
84
94
  }
85
95
  /**
86
96
  * Result of a single lane dispatch (session creation + prompt).
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Ephemeral opencode session teardown — abort-then-delete ordering that closes
3
+ * the FOREIGN KEY constraint race described in issue #2123.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * opencode writes the final assistant `part`/`message` **asynchronously**, in
8
+ * `SessionProcessor.cleanup`, which runs as a stream-drain finalizer that can
9
+ * settle AFTER `session.prompt()` resolves. The `part.message_id` foreign key
10
+ * is `ON DELETE CASCADE`, so a `session.delete()` that lands before that flush
11
+ * cascade-removes the parent `message` row; opencode's late `updatePart`/
12
+ * `updateMessage` then fails with `SQLiteError: FOREIGN KEY constraint failed`
13
+ * (in opencode's own log — the plugin's `session.delete()` promise resolves
14
+ * fine, which is why a `.catch(() => {})` on the delete cannot prevent it).
15
+ *
16
+ * ## Why awaiting `session.abort()` fixes it
17
+ *
18
+ * `POST /session/{id}/abort` resolves only after `SessionRunState.cancel` →
19
+ * `Runner.cancel` → `Fiber.interrupt(runFiber)`. Effect's `Fiber.interrupt` runs
20
+ * every finalizer on the target fiber before resolving, and the run-loop fiber
21
+ * carries `Effect.ensuring(cleanup())`. Therefore, once `await session.abort()`
22
+ * resolves, opencode has already flushed the final part/message (or the session
23
+ * was already idle, in which case `cleanup` ran during natural completion).
24
+ * Source: opencode v1.18.16 — `packages/opencode/src/session/{run-state,runner,
25
+ * processor}.ts`.
26
+ *
27
+ * Because `cleanup` awaits each pending tool-call deferred with a 250 ms
28
+ * timeout, the abort step can block up to ~250 ms+ under interruption. Its
29
+ * bounded timeout must stay well above that (default 5 s) or it would give up
30
+ * before the flush lands and re-introduce the race.
31
+ */
32
+ import { log } from './logger.js';
33
+ /** Minimum lifecycle surface for tearing an ephemeral session down. */
34
+ export interface EphemeralSessionLifecycle {
35
+ /** Server-side abort; optional because some session shims (tests, older hosts) lack it. */
36
+ abort?: (args: {
37
+ path: {
38
+ id: string;
39
+ };
40
+ }) => Promise<unknown>;
41
+ /** Hard delete the session and cascade its rows. */
42
+ delete: (args: {
43
+ path: {
44
+ id: string;
45
+ };
46
+ }) => Promise<unknown>;
47
+ }
48
+ export declare const DEFAULT_EPHEMERAL_ABORT_TIMEOUT_MS = 5000;
49
+ export declare const DEFAULT_EPHEMERAL_TEARDOWN_DELETE_TIMEOUT_MS = 2000;
50
+ export interface TeardownEphemeralSessionOptions {
51
+ /** Bounded timeout for the graceful abort step. Must stay > ~500 ms. */
52
+ abortTimeoutMs?: number;
53
+ /** Bounded timeout for the hard delete step. */
54
+ deleteTimeoutMs?: number;
55
+ /**
56
+ * Skip the abort step. Use only for sessions that were never prompted (no
57
+ * final part flush pending) — e.g. a session that timed out during
58
+ * `session.create` before `session.prompt` was ever called.
59
+ */
60
+ skipAbort?: boolean;
61
+ }
62
+ /**
63
+ * Race a session HTTP call against a bounded timer. The structural lifecycle
64
+ * type carries no `signal` (it must also fit the `SessionOps` shim), so the
65
+ * timer bounds the AWAIT only — it cannot pre-empt the in-flight SDK request,
66
+ * which is allowed to settle in the background. Mirrors `boundedDeleteEphemeralSession`
67
+ * in `src/evaluation/ephemeral-agent-dispatcher.ts`. Best-effort: never throws.
68
+ */
69
+ declare function boundedSessionCall(call: () => Promise<unknown>, timeoutMs: number, timeoutLabel: string, failureLabel: string, sessionId: string): Promise<void>;
70
+ /**
71
+ * Bounded graceful abort. Guarantees opencode has flushed the session's final
72
+ * part/message before resolving (or that the session was already idle). Never
73
+ * throws; logs timeouts/failures via the debug-gated logger.
74
+ */
75
+ export declare function boundedAbortEphemeralSession(session: EphemeralSessionLifecycle, sessionId: string, timeoutMs?: number): Promise<void>;
76
+ /**
77
+ * Bounded hard delete. Never throws; logs timeouts/failures.
78
+ */
79
+ export declare function boundedDeleteEphemeralSession(session: EphemeralSessionLifecycle, sessionId: string, timeoutMs?: number): Promise<void>;
80
+ /**
81
+ * Tear an ephemeral session down without racing opencode's final part/message
82
+ * flush (#2123): a bounded, awaited `session.abort()` (flush) FOLLOWED BY a
83
+ * bounded `session.delete()`. Best-effort: never throws.
84
+ *
85
+ * Callers that need cleanup guaranteed before they return should `await` this.
86
+ * Fire-and-forget callers may `void` it — the abort→delete ordering still holds
87
+ * inside the unit, so the FK race is closed regardless; only the caller's own
88
+ * completion timing is detached (process exit before completion leaks a
89
+ * session, same as the prior fire-and-forget delete, and is harmless).
90
+ */
91
+ export declare function teardownEphemeralSession(session: EphemeralSessionLifecycle, sessionId: string, options?: TeardownEphemeralSessionOptions): Promise<void>;
92
+ export declare const _internals: {
93
+ boundedSessionCall: typeof boundedSessionCall;
94
+ boundedAbort: typeof boundedAbortEphemeralSession;
95
+ boundedDelete: typeof boundedDeleteEphemeralSession;
96
+ log: typeof log;
97
+ };
98
+ export {};
@@ -17,14 +17,107 @@ export declare function containsPathTraversal(str: string): boolean;
17
17
  */
18
18
  export declare function containsControlChars(str: string): boolean;
19
19
  /**
20
- * Validate a directory path for safety.
20
+ * Validate a **project/workspace root** for safety.
21
+ *
22
+ * Distinct from {@link validateDirectory}, which additionally rejects absolute
23
+ * paths and is therefore only correct for a *relative sub-path*. A workspace
24
+ * root is absolute by contract — `ctx.directory` injected by `createSwarmTool`
25
+ * is always an absolute project root (AGENTS.md invariant 4) — so applying
26
+ * `validateDirectory` to one rejects 100% of real inputs and silently kills
27
+ * whatever feature depends on it. That is exactly how `.swarm/run-memory.jsonl`
28
+ * came to have no producer and a consumer that threw on every call.
29
+ *
30
+ * This validator keeps the checks that are meaningful for a root (empty,
31
+ * traversal segments, control/bidi characters). Containment of the actual FILE
32
+ * is not this function's job and is unchanged: `validateSwarmPath(directory,
33
+ * filename)` resolves the target under `<directory>/.swarm`, rejects any path
34
+ * that escapes it, and rejects a symlinked `.swarm` base.
35
+ *
36
+ * It ALSO requires the root to be absolute and to not be a filesystem or system
37
+ * location — see `validateProjectDirectory` below, which this function
38
+ * delegates to, for why those two checks are load-bearing rather than
39
+ * defensive. In short: `validateSwarmPath` pins the write *inside* the root, so
40
+ * it cannot help when the root itself is `E:\` or `/etc`, and a RELATIVE root
41
+ * resolves `.swarm/` against the host process cwd — the same invariant-4 hazard
42
+ * as an empty root.
43
+ *
44
+ * @param directory - The workspace root to validate
45
+ * @throws Error if the root is invalid
46
+ */
47
+ export declare function validateWorkspaceRoot(directory: string): void;
48
+ /**
49
+ * Validate a relative directory path for safety.
21
50
  * Rejects empty paths, paths with traversal, control characters, and absolute paths.
22
51
  * Throws an Error if the directory is invalid.
23
52
  *
53
+ * Do NOT use this on a project/workspace root — see {@link validateWorkspaceRoot}.
54
+ *
24
55
  * @param directory - The directory string to validate
25
56
  * @throws Error if directory is invalid
26
57
  */
27
58
  export declare function validateDirectory(directory: string): void;
59
+ /**
60
+ * Validate a TRUSTED, already-absolute project root directory.
61
+ *
62
+ * This is the trust-model counterpart to `validateDirectory` above, NOT a
63
+ * relaxation of it. `validateDirectory` guards UNTRUSTED, RELATIVE sub-path
64
+ * input and therefore rejects absolute paths by design. A project root that
65
+ * the plugin host injects (`ctx.directory`, or the documented direct-CLI /
66
+ * test `process.cwd()` fallback) is ALWAYS absolute, so handing one to
67
+ * `validateDirectory` throws unconditionally — the misapplication that made
68
+ * the context-budget and run-memory features dead on every real invocation
69
+ * (issue #1619 follow-up).
70
+ *
71
+ * What this still enforces — every check that is meaningful for a root:
72
+ * - Non-empty. An empty root makes `path.resolve('', '.swarm')` land on
73
+ * whatever the host process cwd happens to be, which is an invariant-4
74
+ * (`.swarm/` containment) violation, not a harmless no-op.
75
+ * - No traversal and no control / directional-format characters. A root
76
+ * carrying `..`, a NUL byte, or a bidi override is never a legitimate
77
+ * injected project root.
78
+ * - MUST be absolute. A relative root resolves against the host's process cwd
79
+ * — the same invariant-4 hazard as the empty case, and the reason this is a
80
+ * positive requirement rather than merely "absolute is tolerated".
81
+ *
82
+ * WHAT "ABSOLUTE" MEANS HERE IS PARTLY PLATFORM-DEPENDENT. The check is
83
+ * `path.isAbsolute(directory) || /^[A-Za-z]:[/\\]/.test(directory)`, and
84
+ * `path.isAbsolute` is bound to the host platform. Measured, not assumed
85
+ * (2026-08-10, issue #1619 review round 4, F5):
86
+ *
87
+ * | root | POSIX host | Windows host |
88
+ * | --------------------------- | ---------- | ------------ |
89
+ * | `/srv/app` | accepted | accepted |
90
+ * | `C:/app`, `C:\app` | accepted | accepted |
91
+ * | `//server/share/project` | accepted | accepted |
92
+ * | `\\server\share\project` | REJECTED | accepted |
93
+ * | `app/relative` | rejected | rejected |
94
+ *
95
+ * So the drive-letter fallback is what makes Windows drive roots portable, and
96
+ * a POSIX root is absolute on Windows too (`path.win32.isAbsolute('/srv')` is
97
+ * true — a driveless rooted path passes there, resolving against the current
98
+ * drive). But the BACKSLASH UNC form is not portable: it is win32-absolute and
99
+ * not posix-absolute, and no fallback covers it, so it validates on Windows and
100
+ * throws on a Linux CI runner. Only the forward-slash UNC spelling is accepted
101
+ * on both.
102
+ *
103
+ * UNC paths are intentionally accepted ON WINDOWS: they are absolute there and
104
+ * can be a legitimate project root. Rejecting them would silently re-create the
105
+ * dead-feature class this function exists to fix, because every caller sits
106
+ * behind a debug-gated catch. Nothing is lost by the POSIX rejection — a
107
+ * `\\server\share` root is not a usable path on POSIX in the first place.
108
+ *
109
+ * NOT interchangeable with `validateProjectRoot` (src/evidence/manager.ts).
110
+ * That one is a filesystem-touching, fail-closed check that the directory is
111
+ * the OUTERMOST project root (no ancestor owns a `.swarm/`). It does I/O
112
+ * (realpathSync plus a bounded ancestor walk) on every call and it correctly
113
+ * REJECTS a linked git worktree whose parent checkout has a `.swarm/` — right
114
+ * for a one-shot evidence write, wrong for a per-turn chat-transform hook.
115
+ * Reserve it for writes that must be pinned to the outermost project root.
116
+ *
117
+ * @param directory - the injected, trusted project root
118
+ * @throws Error if the directory is not a usable absolute project root
119
+ */
120
+ export declare function validateProjectDirectory(directory: string): void;
28
121
  /**
29
122
  * Validate that a resolved path stays within an allowed root directory.
30
123
  * Resolves symlinks via realpathSync for both the target path and the root,
@@ -105,6 +198,7 @@ export declare const _internals: {
105
198
  containsPathTraversal: typeof containsPathTraversal;
106
199
  containsControlChars: typeof containsControlChars;
107
200
  validateDirectory: typeof validateDirectory;
201
+ validateProjectDirectory: typeof validateProjectDirectory;
108
202
  validateSymlinkBoundary: typeof validateSymlinkBoundary;
109
203
  isCanonicalPathWithinRoot: typeof isCanonicalPathWithinRoot;
110
204
  validateTargetWithinRoot: typeof validateTargetWithinRoot;
@@ -1,3 +1,4 @@
1
+ import * as fsSync from 'node:fs';
1
2
  export interface SwarmArtifactCacheStats {
2
3
  textReadCount: number;
3
4
  textCacheHitCount: number;
@@ -14,6 +15,18 @@ export interface SwarmArtifactCacheStats {
14
15
  textEntryCount: number;
15
16
  parsedEntryCount: number;
16
17
  }
18
+ /**
19
+ * Dependency-injection seam for testing. Tests can temporarily replace these
20
+ * to force a specific (e.g. colliding) stat stamp without relying on
21
+ * filesystem/platform-specific ctime semantics (utimesSync cannot set ctime
22
+ * portably — see swarm-artifact-cache.test.ts's documented Windows/POSIX
23
+ * divergence). Restore each entry in afterEach via the saved original
24
+ * reference.
25
+ */
26
+ export declare const _internals: {
27
+ stat: typeof fsSync.promises.stat;
28
+ statSync: fsSync.StatSyncFn;
29
+ };
17
30
  export declare function cloneCachedValue<T>(value: T): T;
18
31
  export declare function readCachedTextFileSync(filePath: string, directRead: () => string | null): string | null;
19
32
  export declare function readCachedTextFile(filePath: string, directRead: () => Promise<string | null>): Promise<string | null>;
@@ -1,5 +1,5 @@
1
1
  export type { AutoCommitSkip, AutoCommitSuccess, CleanCheckFailure, CleanCheckSuccess, CleanFailure, CleanSuccess, ProvisionFailure, ProvisionSuccess, RemoveFailure, RemoveSuccess, } from './core';
2
2
  export { _internals as coreInternals, assertCleanWorkingTree, autoCommitDirty, checkPathBudget, cleanUntrackedFiles, isCleanWorktree, isPathUnderSwarmWorktreeBase, makeWorktreeBranchName, provisionWorktree, removeWorktree, resolveWorktreeBaseDir, shortenWorktreePath, } from './core';
3
3
  export type { CleanupFailure, CleanupSuccess, ConflictHandlingError, ConflictInfo, DirtyMergeFailure, DirtyMergePartial, DirtyMergeSuccess, MergeConflict, MergeFailure, MergeSuccess, OrphanCleanupResult, StartupRecoveryResult, } from './merge';
4
- export { _internals as mergeInternals, attemptMergeBackFromDirty, cleanupOrphanedBranches, getMergeStrategy, handleMergeConflict, mergeLaneBranch, postMergeCleanup, startupOrphanRecovery, } from './merge';
4
+ export { _internals as mergeInternals, attemptMergeBackFromDirty, cleanupOrphanedBranches, getMergeStrategy, handleMergeConflict, mergeLaneBranch, postMergeCleanup, pruneStaleWorktreeMetadata, scanRegisteredWorktreeLiveness, startupOrphanRecovery, } from './merge';
5
5
  export * from './types';
@@ -130,6 +130,14 @@ export interface OrphanCleanupResult {
130
130
  * deletions were skipped this pass (fail-safe). */
131
131
  recoveryReadError?: boolean;
132
132
  }
133
+ export interface OrphanCleanupOptions {
134
+ /**
135
+ * Preserve branches that are not merged into HEAD. Interactive reset flows
136
+ * retain the historical force-delete default; unattended startup recovery
137
+ * sets this to true so a missing worktree cannot erase its only commits.
138
+ */
139
+ preserveUnmerged?: boolean;
140
+ }
133
141
  export interface StartupRecoveryResult {
134
142
  prunedWorktrees: boolean;
135
143
  remainingBranches: string[];
@@ -180,6 +188,30 @@ export declare function reconcileLandedMerge(primaryDir: string, provenance: Mer
180
188
  * @returns Discriminated union: success, partial failure, or full failure.
181
189
  */
182
190
  export declare function postMergeCleanup(directory: string, branchName: string): Promise<CleanupSuccess | CleanupFailure>;
191
+ /**
192
+ * Remove only stale Git worktree registration metadata. This deliberately does
193
+ * not delete a lane branch; callers hand branch reconciliation to
194
+ * `provisionWorktree`, which refuses branches with unmerged commits and uses
195
+ * non-forced `git branch -d` for proven-merged stale branches.
196
+ */
197
+ export declare function pruneStaleWorktreeMetadata(directory: string): Promise<{
198
+ pruned: true;
199
+ } | {
200
+ error: string;
201
+ }>;
202
+ export type RegisteredWorktreeLivenessScan = {
203
+ status: 'ok';
204
+ liveBranches: string[];
205
+ } | {
206
+ status: 'uncertain';
207
+ reason: string;
208
+ };
209
+ /**
210
+ * Enumerate branches whose registered worktree paths still exist. Stale Git
211
+ * metadata with a missing path is deliberately excluded so an expired
212
+ * provisional owner cannot become permanent ownership after a crash.
213
+ */
214
+ export declare function scanRegisteredWorktreeLiveness(directory: string): Promise<RegisteredWorktreeLivenessScan>;
183
215
  /**
184
216
  * Handles a merge conflict by listing conflicted files and aborting the
185
217
  * in-progress operation to restore the working tree to a clean state.
@@ -228,14 +260,15 @@ declare function extractSessionId(branchName: string): string | null;
228
260
  * Cleans up orphaned swarm-lane branches that do not belong to any active session.
229
261
  *
230
262
  * Lists all swarm-lane/ and swarm/lane/ branches, identifies orphans (branches whose
231
- * session ID is not in `activeSessionIds`), force-deletes them, and prunes stale
232
- * worktree metadata.
263
+ * session ID is not in `activeSessionIds`), deletes them according to the
264
+ * requested preservation policy, and prunes stale worktree metadata.
233
265
  *
234
266
  * @param directory - The project root (cwd for all git commands).
235
267
  * @param activeSessionIds - Session IDs that are still active; their branches are skipped.
268
+ * @param options - Branch preservation policy for unattended recovery.
236
269
  * @returns Result with arrays of removed, skipped, and errored branch names.
237
270
  */
238
- export declare function cleanupOrphanedBranches(directory: string, activeSessionIds?: string[]): Promise<OrphanCleanupResult>;
271
+ export declare function cleanupOrphanedBranches(directory: string, activeSessionIds?: string[], options?: OrphanCleanupOptions): Promise<OrphanCleanupResult>;
239
272
  /**
240
273
  * Performs startup orphan recovery: prunes stale worktrees, then identifies
241
274
  * any remaining orphaned swarm-lane branches for warning.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-swarm",
3
- "version": "7.138.4",
3
+ "version": "7.139.1",
4
4
  "description": "Architect-centric agentic swarm plugin for OpenCode - hub-and-spoke orchestration with SME consultation, code generation, and QA review",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",