makdoong2-team 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/agents/makdoong2-analyzer.md +135 -0
  4. package/agents/makdoong2-engineer.md +165 -0
  5. package/agents/makdoong2-planner.md +267 -0
  6. package/agents/makdoong2-publisher.md +481 -0
  7. package/agents/makdoong2-team-leader.md +249 -0
  8. package/agents/makdoong2-verifier.md +353 -0
  9. package/assets/makdoong2-team.default.json +46 -0
  10. package/assets/makdoong2-team.schema.json +357 -0
  11. package/bin/cli.js +404 -0
  12. package/dist/agent-stage-config.d.ts +15 -0
  13. package/dist/agent-stage-config.js +119 -0
  14. package/dist/config.d.ts +79 -0
  15. package/dist/config.js +96 -0
  16. package/dist/logger.d.ts +14 -0
  17. package/dist/logger.js +131 -0
  18. package/dist/mcp-secret-injector.d.ts +56 -0
  19. package/dist/mcp-secret-injector.js +89 -0
  20. package/dist/model-chain-cli.d.ts +1 -0
  21. package/dist/model-chain-cli.js +21 -0
  22. package/dist/model-fallback-policy.d.ts +69 -0
  23. package/dist/model-fallback-policy.js +211 -0
  24. package/dist/opencode-plugin.d.ts +8 -0
  25. package/dist/opencode-plugin.js +2457 -0
  26. package/dist/poll-sub-session.d.ts +139 -0
  27. package/dist/poll-sub-session.js +494 -0
  28. package/dist/redact-secrets.d.ts +3 -0
  29. package/dist/redact-secrets.js +68 -0
  30. package/dist/session-index.d.ts +11 -0
  31. package/dist/session-index.js +71 -0
  32. package/dist/skill-mcp-registry.d.ts +59 -0
  33. package/dist/skill-mcp-registry.js +178 -0
  34. package/dist/stall-escalation.d.ts +1 -0
  35. package/dist/stall-escalation.js +22 -0
  36. package/dist/tmux-monitor.d.ts +193 -0
  37. package/dist/tmux-monitor.js +694 -0
  38. package/dist/verdict-hash.d.ts +1 -0
  39. package/dist/verdict-hash.js +62 -0
  40. package/gates/stage-analysis-verify.sh +84 -0
  41. package/gates/stage2-requirements-verify.sh +13 -0
  42. package/gates/stage3-scope-verify.sh +45 -0
  43. package/gates/stage4-dev-post-verify.sh +64 -0
  44. package/gates/stage4-dev-verify.sh +36 -0
  45. package/gates/stage5-coverage-verify.sh +36 -0
  46. package/gates/stage5-test-verify.sh +24 -0
  47. package/gates/stage6-commit-verify.sh +41 -0
  48. package/gates/stage6-post-commit-verify.sh +131 -0
  49. package/gates/stage7-post-pr-verify.sh +53 -0
  50. package/gates/stage7-pr-verify.sh +48 -0
  51. package/gates/stage8-post-review-verify.sh +84 -0
  52. package/gates/stage8-review-verify.sh +45 -0
  53. package/gates/verify.sh +44 -0
  54. package/opencode.json.example +40 -0
  55. package/package.json +84 -0
  56. package/postinstall.mjs +56 -0
  57. package/references/commit-convention.md +130 -0
  58. package/references/jira-issue-templates.md +203 -0
  59. package/references/pr-template.md +381 -0
  60. package/scripts/config.sh +46 -0
  61. package/scripts/coverage-record.sh +67 -0
  62. package/scripts/gate-policy-test.sh +152 -0
  63. package/scripts/install-lib.mjs +1029 -0
  64. package/scripts/lint-agent-prompts.sh +74 -0
  65. package/scripts/log-event.sh +44 -0
  66. package/scripts/model-policy.mjs +183 -0
  67. package/scripts/publish-if-changed.sh +207 -0
  68. package/scripts/release.sh +276 -0
  69. package/scripts/rollback-commits.sh +35 -0
  70. package/scripts/smoke-test.mjs +194 -0
  71. package/scripts/state.sh +192 -0
  72. package/scripts/test-postinstall.mjs +141 -0
  73. package/scripts/with-fallback.sh +56 -0
  74. package/scripts/wt-sync-ignored.sh +193 -0
  75. package/skills/_lib/load-secret.sh +149 -0
  76. package/skills/bamboo-ci/SKILL.md +81 -0
  77. package/skills/bamboo-ci/run-bamboo.sh +23 -0
  78. package/skills/bitbucket-research/SKILL.md +87 -0
  79. package/skills/bitbucket-research/run-repos.sh +23 -0
  80. package/skills/confluence-research/SKILL.md +75 -0
  81. package/skills/confluence-research/run-docs.sh +23 -0
  82. package/skills/github-oss-research/SKILL.md +59 -0
  83. package/skills/jira-research/SKILL.md +75 -0
  84. package/skills/jira-research/run-works.sh +23 -0
  85. package/src/hooks/guard-bash.sh +67 -0
  86. package/src/hooks/session-start.sh +96 -0
  87. package/src/hooks/sync-state.sh +47 -0
  88. package/stages/01-jira.md +43 -0
  89. package/stages/01-planning.md +229 -0
  90. package/stages/02-requirements.md +298 -0
  91. package/stages/03-scope.md +81 -0
  92. package/stages/04-analysis.md +281 -0
  93. package/stages/05-worktree-dev.md +124 -0
  94. package/stages/06-test.md +161 -0
  95. package/stages/07-commit.md +229 -0
  96. package/stages/08-pr.md +177 -0
  97. package/stages/09-review-comments.md +277 -0
@@ -0,0 +1,139 @@
1
+ export type MessagePartLike = {
2
+ type: string;
3
+ text?: string;
4
+ };
5
+ export type MessageInfoLike = {
6
+ id?: string;
7
+ role: string;
8
+ finish?: unknown;
9
+ };
10
+ export type MessageLike = {
11
+ info: MessageInfoLike;
12
+ parts: MessagePartLike[];
13
+ };
14
+ export type StatusEntryLike = {
15
+ type: string;
16
+ };
17
+ export type PermissionRequestLike = {
18
+ id: string;
19
+ sessionID: string;
20
+ permission: string;
21
+ patterns: string[];
22
+ };
23
+ export interface PollClientLike {
24
+ session: {
25
+ status: () => Promise<{
26
+ data?: Record<string, StatusEntryLike>;
27
+ } | null | undefined>;
28
+ messages: (req: {
29
+ path: {
30
+ id: string;
31
+ };
32
+ }) => Promise<{
33
+ data?: MessageLike[];
34
+ } | null | undefined>;
35
+ abort: (req: {
36
+ path: {
37
+ id: string;
38
+ };
39
+ }) => Promise<unknown>;
40
+ };
41
+ permission?: {
42
+ list: () => Promise<{
43
+ data?: PermissionRequestLike[];
44
+ } | null | undefined>;
45
+ reply: (req: {
46
+ path: {
47
+ requestID: string;
48
+ };
49
+ body: {
50
+ reply: string;
51
+ };
52
+ }) => Promise<unknown>;
53
+ };
54
+ }
55
+ export type PollOutcome = {
56
+ kind: "text";
57
+ text: string;
58
+ polls: number;
59
+ elapsedMs: number;
60
+ } | {
61
+ kind: "empty";
62
+ reason: string;
63
+ polls: number;
64
+ elapsedMs: number;
65
+ } | {
66
+ kind: "timeout";
67
+ polls: number;
68
+ elapsedMs: number;
69
+ transientFailures: number;
70
+ } | {
71
+ kind: "aborted";
72
+ reason: string;
73
+ polls: number;
74
+ elapsedMs: number;
75
+ } | {
76
+ kind: "permission_stall";
77
+ polls: number;
78
+ elapsedMs: number;
79
+ stalledMs: number;
80
+ permissionID?: string;
81
+ permissionType?: string;
82
+ } | {
83
+ kind: "session_gone";
84
+ polls: number;
85
+ elapsedMs: number;
86
+ reason?: string;
87
+ };
88
+ export interface PollOptions {
89
+ timeoutMs?: number;
90
+ pollIntervalMs?: number;
91
+ pollSafetyMargin?: number;
92
+ permissionCheckIntervalPolls?: number;
93
+ allowedWorktree?: string;
94
+ configuredAllowPatterns?: string[];
95
+ now?: () => number;
96
+ sleep?: (ms: number) => Promise<void>;
97
+ logger?: {
98
+ debug?: (msg: string) => void;
99
+ error?: (msg: string) => void;
100
+ };
101
+ toolCallStallThresholdMs?: number;
102
+ messageStallThresholdMs?: number;
103
+ statusAbsentGraceMs?: number;
104
+ isRecentlyActive?: () => boolean;
105
+ contentStableCompletionMs?: number;
106
+ preambleOnlyTextThreshold?: number;
107
+ nudgeAtFraction?: number;
108
+ onNudge?: (sessionId: string, elapsedMs: number) => Promise<void>;
109
+ }
110
+ export declare function isWithinWorktreeScope(patterns: string[], worktree: string): boolean;
111
+ export declare function isMatchedByConfiguredRules(patterns: string[], allowedGlobs: string[]): boolean;
112
+ /**
113
+ * Poll a sub-session until it becomes idle (or times out) and extract the
114
+ * last assistant response as plain text.
115
+ *
116
+ * The returned {@link PollOutcome} discriminates four terminal states so that
117
+ * callers can set the "success" flag correctly:
118
+ *
119
+ * - `text` : sub-agent produced final text output. success=true.
120
+ * - `empty` : sub-agent completed but produced no text parts. success=false.
121
+ * - `timeout` : deadline exceeded. abort() invoked. success=false.
122
+ * - `aborted` : safety limit hit (maxPolls). abort() invoked. success=false.
123
+ *
124
+ * Callers previously conflated "text" and "empty" via a fallback string
125
+ * `"(session complete, no text output)"`, which made the orchestrator report
126
+ * success even when the sub-agent produced nothing. This function forces the
127
+ * distinction so `dispatch_stage` can return `ok:false` on empty output.
128
+ */
129
+ export declare function pollSubSession(client: PollClientLike, sessionId: string, options?: PollOptions): Promise<PollOutcome>;
130
+ /**
131
+ * Convert a {@link PollOutcome} into a display string plus a boolean success
132
+ * flag. Used by callers that need a single string for downstream serialization
133
+ * (e.g., embedding in a JSON response) but must also know whether the
134
+ * sub-agent actually produced work.
135
+ */
136
+ export declare function pollOutcomeToLegacy(outcome: PollOutcome): {
137
+ text: string;
138
+ success: boolean;
139
+ };
@@ -0,0 +1,494 @@
1
+ // poll-sub-session.ts — pure, testable polling primitive for sub-sessions.
2
+ //
3
+ // Extracted from opencode-plugin.ts so that end-conditions (idle, finish,
4
+ // timeout, transient failures, empty parts) can be unit-tested with a mock
5
+ // client. The plugin passes its real `client` at call sites; tests inject a
6
+ // scripted mock via the same shape.
7
+ //
8
+ // Design invariants (see AGENTS.md and Oracle diagnosis for rationale):
9
+ // INV-1: `!status` alone must never mean "idle". A missing status entry can
10
+ // equally mean "session not registered yet" or "session cleaned up".
11
+ // Idle is confirmed only by `status.type === "idle"` AFTER we have
12
+ // seen the session appear in the status map at least once.
13
+ // INV-2: transient `session.status()` / `session.messages()` failures must
14
+ // be retried, not silently treated as "no messages". They advance
15
+ // `transientFailures` counter and are surfaced when polling ends.
16
+ // INV-3: ordering is derived from message array index, not from lexicographic
17
+ // ID comparison. Message IDs may be UUIDs (non-monotonic strings).
18
+ // INV-4: the result must distinguish (a) sub-agent produced text, (b) idle
19
+ // but no text (still an error case for callers), (c) transient
20
+ // failure timeout, (d) hard timeout. Callers set `success` per this
21
+ // discrimination.
22
+ const TOOL_CALL_PART_TYPES = new Set(["tool_call", "tool-call", "tool_use"]);
23
+ // path.posix.dirname is used directly so the module works in both Node and test
24
+ // environments without importing 'path'. The separator is always '/' because
25
+ // opencode worktree paths are POSIX.
26
+ function posixDirname(p) {
27
+ const trimmed = p.replace(/\/+$/, "");
28
+ const idx = trimmed.lastIndexOf("/");
29
+ return idx <= 0 ? "/" : trimmed.slice(0, idx);
30
+ }
31
+ // Returns true when every permission pattern resides within the allowed scope,
32
+ // defined as the parent directory of the worktree (siblings are the main repo
33
+ // and other worktrees — all legitimate access targets for engineers).
34
+ // Trailing glob suffixes (/* , /*/ , /**/ , /**) are stripped before prefix check.
35
+ export function isWithinWorktreeScope(patterns, worktree) {
36
+ if (!worktree || patterns.length === 0)
37
+ return false;
38
+ const scope = posixDirname(worktree);
39
+ return patterns.every(pat => {
40
+ const base = pat.replace(/\/?(\*+\/?)+$/, "");
41
+ return base === scope || base.startsWith(scope + "/");
42
+ });
43
+ }
44
+ // Returns true when every permission pattern is covered by at least one
45
+ // configured allow glob from opencode.json's external_directory section.
46
+ //
47
+ // Deliberately uses prefix comparison rather than path.matchesGlob because
48
+ // matchesGlob's "**" does NOT traverse dot-prefixed directories (.nvm, .config).
49
+ // opencode.json patterns that contain dot-directories must therefore be written
50
+ // as absolute-path prefixes (e.g. "/root/.nvm/**") rather than middle-wildcard
51
+ // globs ("**/@local/**") to work reliably in both opencode and this function.
52
+ export function isMatchedByConfiguredRules(patterns, allowedGlobs) {
53
+ if (patterns.length === 0 || allowedGlobs.length === 0)
54
+ return false;
55
+ return patterns.every(pat => {
56
+ const base = pat.replace(/\/?(\*+\/?)+$/, "");
57
+ return allowedGlobs.some(glob => {
58
+ const globBase = glob.replace(/\/?(\*+\/?)+$/, "");
59
+ return base === globBase || base.startsWith(globBase + "/");
60
+ });
61
+ });
62
+ }
63
+ /**
64
+ * Poll a sub-session until it becomes idle (or times out) and extract the
65
+ * last assistant response as plain text.
66
+ *
67
+ * The returned {@link PollOutcome} discriminates four terminal states so that
68
+ * callers can set the "success" flag correctly:
69
+ *
70
+ * - `text` : sub-agent produced final text output. success=true.
71
+ * - `empty` : sub-agent completed but produced no text parts. success=false.
72
+ * - `timeout` : deadline exceeded. abort() invoked. success=false.
73
+ * - `aborted` : safety limit hit (maxPolls). abort() invoked. success=false.
74
+ *
75
+ * Callers previously conflated "text" and "empty" via a fallback string
76
+ * `"(session complete, no text output)"`, which made the orchestrator report
77
+ * success even when the sub-agent produced nothing. This function forces the
78
+ * distinction so `dispatch_stage` can return `ok:false` on empty output.
79
+ */
80
+ export async function pollSubSession(client, sessionId, options = {}) {
81
+ const timeoutMs = options.timeoutMs ?? 1_800_000;
82
+ const pollIntervalMs = options.pollIntervalMs ?? 2_000;
83
+ const pollSafetyMargin = options.pollSafetyMargin ?? 10;
84
+ const now = options.now ?? Date.now;
85
+ const sleep = options.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
86
+ const dbg = options.logger?.debug;
87
+ const err = options.logger?.error ?? ((msg) => console.error(msg));
88
+ const startTime = now();
89
+ const deadline = startTime + timeoutMs;
90
+ const expectedPolls = Math.ceil(timeoutMs / pollIntervalMs);
91
+ const maxPolls = expectedPolls + pollSafetyMargin;
92
+ const toolCallStallThresholdMs = options.toolCallStallThresholdMs ?? 60_000;
93
+ const messageStallThresholdMs = options.messageStallThresholdMs;
94
+ const statusAbsentGraceMs = options.statusAbsentGraceMs ?? 300_000;
95
+ const contentStableCompletionMs = options.contentStableCompletionMs;
96
+ const preambleOnlyTextThreshold = options.preambleOnlyTextThreshold;
97
+ const isRecentlyActive = options.isRecentlyActive;
98
+ const permissionCheckIntervalPolls = options.permissionCheckIntervalPolls ?? 5;
99
+ let pollCount = 0;
100
+ let transientFailures = 0;
101
+ let sessionEverAppeared = false;
102
+ let hasProducedAssistantMessage = false;
103
+ let lastProgressAt = startTime;
104
+ let lastSeenMessageCount = -1;
105
+ let lastAssistantSig = "";
106
+ let nudged = false;
107
+ let firstGoneObservedAt = null;
108
+ dbg?.(`[pollSubSession] START session=${sessionId} timeoutMs=${timeoutMs}`);
109
+ while (now() < deadline) {
110
+ await sleep(pollIntervalMs);
111
+ pollCount++;
112
+ if (pollCount > maxPolls) {
113
+ err(`[pollSubSession] ABORT session=${sessionId} reason=max_polls_exceeded count=${pollCount}/${maxPolls}`);
114
+ await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
115
+ return {
116
+ kind: "aborted",
117
+ reason: `exceeded max polling attempts ${pollCount}/${maxPolls}`,
118
+ polls: pollCount,
119
+ elapsedMs: now() - startTime,
120
+ };
121
+ }
122
+ let statusFailed = false;
123
+ const statusResult = await client.session.status().catch((e) => {
124
+ err(`[pollSubSession] session.status() FAILED session=${sessionId} poll=${pollCount} error=${e}`);
125
+ statusFailed = true;
126
+ return null;
127
+ });
128
+ const allStatuses = statusResult?.data;
129
+ const status = allStatuses?.[sessionId];
130
+ if (status)
131
+ sessionEverAppeared = true;
132
+ if (statusFailed)
133
+ transientFailures++;
134
+ let messagesFailed = false;
135
+ const msgResult = await client.session
136
+ .messages({ path: { id: sessionId } })
137
+ .catch((e) => {
138
+ err(`[pollSubSession] session.messages() FAILED session=${sessionId} poll=${pollCount} error=${e}`);
139
+ messagesFailed = true;
140
+ return null;
141
+ });
142
+ const messages = msgResult?.data ?? [];
143
+ if (messagesFailed)
144
+ transientFailures++;
145
+ // INV-2: transient failures do not advance completion detection. Just retry.
146
+ if (statusFailed || messagesFailed) {
147
+ dbg?.(`[pollSubSession] TRANSIENT session=${sessionId} poll=${pollCount} statusFailed=${statusFailed} messagesFailed=${messagesFailed}`);
148
+ continue;
149
+ }
150
+ const lengthChanged = messages.length !== lastSeenMessageCount;
151
+ if (lengthChanged)
152
+ lastSeenMessageCount = messages.length;
153
+ // sessionEverAppeared stays strict (status-map only). A second liveness
154
+ // signal, sessionAliveByMessages, unlocks defense paths for worktree-CWD
155
+ // sessions where the server's status map is CWD-filtered and never
156
+ // contains the sessionId. Keeping the two signals separate lets
157
+ // session_gone (status_absent) fire immediately for the strict case
158
+ // (status was present, then disappeared — real disappearance) while
159
+ // requiring an additional hasProducedAssistantMessage gate for the loose
160
+ // case (worktree — session was never in status map, so we can't
161
+ // distinguish bootstrap from real gone without prior output evidence).
162
+ const sessionAliveByMessages = messages.length > 0;
163
+ // INV-3: ordering is derived from array index, not lexicographic ID compare.
164
+ let lastAssistantIdx = -1;
165
+ let lastUserIdx = -1;
166
+ for (let i = messages.length - 1; i >= 0; i--) {
167
+ if (lastAssistantIdx < 0 && messages[i].info.role === "assistant")
168
+ lastAssistantIdx = i;
169
+ if (lastUserIdx < 0 && messages[i].info.role === "user")
170
+ lastUserIdx = i;
171
+ if (lastAssistantIdx >= 0 && lastUserIdx >= 0)
172
+ break;
173
+ }
174
+ const lastAssistant = lastAssistantIdx >= 0 ? messages[lastAssistantIdx] : undefined;
175
+ if (lastAssistant)
176
+ hasProducedAssistantMessage = true;
177
+ // Content signature captures streaming text appends and new part additions
178
+ // that leave messages.length unchanged. Without this, an assistant that
179
+ // streams a 5-minute reasoning block into a single message would appear
180
+ // frozen to length-only comparison and could trigger status_absent gone
181
+ // (Oracle-flagged risk #1). Combining length-change with signature-change
182
+ // gives the correct "any progress?" signal for both single-message
183
+ // streaming and normal multi-message flows.
184
+ const lastAsstTextLen = lastAssistant?.parts
185
+ ? lastAssistant.parts.reduce((s, p) => s + (typeof p.text === "string" ? p.text.length : 0), 0)
186
+ : 0;
187
+ const lastAsstPartsLen = lastAssistant?.parts?.length ?? 0;
188
+ const currentAsstSig = lastAssistant
189
+ ? `${lastAssistant.info?.id ?? ""}:${lastAsstPartsLen}:${lastAsstTextLen}`
190
+ : "";
191
+ const contentChanged = currentAsstSig !== "" && currentAsstSig !== lastAssistantSig;
192
+ const messagesChanged = lengthChanged || contentChanged;
193
+ if (messagesChanged) {
194
+ lastAssistantSig = currentAsstSig;
195
+ lastProgressAt = now();
196
+ }
197
+ const properOrdering = lastAssistant && lastUserIdx >= 0
198
+ ? lastAssistantIdx > lastUserIdx
199
+ : Boolean(lastAssistant);
200
+ const hasFinish = lastAssistant?.info?.finish != null;
201
+ const hasPendingToolCall = !!lastAssistant?.parts?.some(p => TOOL_CALL_PART_TYPES.has(p.type));
202
+ const stalledMs = now() - lastProgressAt;
203
+ // session_gone (status_absent) has two admission paths with different
204
+ // strictness:
205
+ // strict — status was previously present (opencode confirmed the
206
+ // session, then it vanished from the map).
207
+ // loose — worktree-CWD case, status never present. Requires prior
208
+ // assistant output to guard against slow-first-token bootstrap
209
+ // false-positive; the correct path for bootstrap hang is
210
+ // message_stall (opt-in threshold), not gone-detection.
211
+ //
212
+ // Both paths ALSO require `!isRecentlyActive()` — an external liveness
213
+ // callback (typically wired to the opencode plugin's tool.execute.before
214
+ // hook) that observes tool activity even when server-side status pushes
215
+ // stall. Recent tool activity resets both the admission and the
216
+ // grace-period timer, so a session that fires tools every few seconds
217
+ // will never be classified gone regardless of how long the status map
218
+ // lags behind.
219
+ //
220
+ // Once admitted, the outcome is not fired until `statusAbsentGraceMs`
221
+ // has elapsed since the FIRST admission observation (not since start of
222
+ // poll). Historical logic used a 3-poll counter (~8 s) which produced
223
+ // heavy false positives on slow-first-token models. The grace-period
224
+ // model keeps timing decoupled from poll frequency and yields intuitive
225
+ // wall-clock semantics.
226
+ const activeSignal = isRecentlyActive?.() === true;
227
+ const goneAdmitted = !activeSignal &&
228
+ !hasPendingToolCall &&
229
+ !messagesChanged &&
230
+ !status &&
231
+ (sessionEverAppeared || (sessionAliveByMessages && hasProducedAssistantMessage));
232
+ if (goneAdmitted) {
233
+ if (firstGoneObservedAt === null) {
234
+ firstGoneObservedAt = now();
235
+ dbg?.(`[pollSubSession] GONE_ADMIT session=${sessionId} poll=${pollCount} ` +
236
+ `sig="${currentAsstSig}" pending_tool=${hasPendingToolCall} ` +
237
+ `session_ever_appeared=${sessionEverAppeared} alive_by_msgs=${sessionAliveByMessages} ` +
238
+ `messages=${messages.length} grace_ms=${statusAbsentGraceMs} ` +
239
+ `— gone admission started; will fire in ${statusAbsentGraceMs}ms if condition persists`);
240
+ }
241
+ const goneElapsedMs = now() - firstGoneObservedAt;
242
+ if (goneElapsedMs >= statusAbsentGraceMs) {
243
+ err(`[pollSubSession] SESSION_GONE session=${sessionId} polls=${pollCount} ` +
244
+ `gone_elapsed_ms=${goneElapsedMs} grace_ms=${statusAbsentGraceMs} ` +
245
+ `sig="${currentAsstSig}" messages=${messages.length} ` +
246
+ `— status disappeared after appearing, no new messages, no recent tool activity`);
247
+ return {
248
+ kind: "session_gone",
249
+ polls: pollCount,
250
+ elapsedMs: now() - startTime,
251
+ };
252
+ }
253
+ }
254
+ else {
255
+ if (firstGoneObservedAt !== null) {
256
+ dbg?.(`[pollSubSession] GONE_ADMIT_RESET session=${sessionId} poll=${pollCount} ` +
257
+ `active_signal=${activeSignal} pending_tool=${hasPendingToolCall} ` +
258
+ `messages_changed=${messagesChanged} status=${status?.type ?? "absent"} ` +
259
+ `— gone admission cleared before grace elapsed`);
260
+ }
261
+ firstGoneObservedAt = null;
262
+ }
263
+ if (client.permission && pollCount % permissionCheckIntervalPolls === 0) {
264
+ const permResult = await client.permission.list().catch(() => null);
265
+ const pending = (permResult?.data ?? []).filter(p => p.sessionID === sessionId);
266
+ for (const p of pending) {
267
+ const withinScope = p.permission === "external_directory" && ((!!options.allowedWorktree && isWithinWorktreeScope(p.patterns, options.allowedWorktree)) ||
268
+ isMatchedByConfiguredRules(p.patterns, options.configuredAllowPatterns ?? []));
269
+ if (withinScope) {
270
+ dbg?.(`[pollSubSession] PERMISSION_ALLOW session=${sessionId} polls=${pollCount}` +
271
+ ` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
272
+ ` scope=dirname(${options.allowedWorktree})`);
273
+ await client.permission
274
+ .reply({ path: { requestID: p.id }, body: { reply: "once" } })
275
+ .catch(() => undefined);
276
+ }
277
+ else {
278
+ err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount}` +
279
+ ` permissionID=${p.id} type=${p.permission} patterns=${JSON.stringify(p.patterns)}` +
280
+ ` — auto-rejecting (outside worktree scope or non-external_directory)`);
281
+ await client.permission
282
+ .reply({ path: { requestID: p.id }, body: { reply: "reject" } })
283
+ .catch(() => undefined);
284
+ await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
285
+ return {
286
+ kind: "permission_stall",
287
+ polls: pollCount,
288
+ elapsedMs: now() - startTime,
289
+ stalledMs,
290
+ permissionID: p.id,
291
+ permissionType: p.permission,
292
+ };
293
+ }
294
+ }
295
+ }
296
+ if (hasPendingToolCall && stalledMs >= toolCallStallThresholdMs) {
297
+ err(`[pollSubSession] PERMISSION_STALL session=${sessionId} polls=${pollCount} stalledMs=${stalledMs}`);
298
+ await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
299
+ return {
300
+ kind: "permission_stall",
301
+ polls: pollCount,
302
+ elapsedMs: now() - startTime,
303
+ stalledMs,
304
+ };
305
+ }
306
+ // Message stall: sub-agent registered (sessionEverAppeared) and appears
307
+ // busy, but has made no progress within the configured threshold.
308
+ // Progress = new message OR content-signature change on the last
309
+ // assistant message (see the `messagesChanged` computation above).
310
+ // Catches two hang modes:
311
+ // (a) bootstrap hang — no assistant message at all (upstream silent,
312
+ // quota exhausted, local server crash). `lastProgressAt` stays at
313
+ // startTime so `stalledFromProgress` grows monotonically.
314
+ // (b) mid-stream LLM inference hang — assistant appeared but then
315
+ // froze mid-response. `lastProgressAt` last advanced when the
316
+ // final signature change was seen; from that point onward the
317
+ // LLM is silent while status stays busy.
318
+ // Heavy tool executions (docker/gradle builds spanning minutes) are
319
+ // exempted via `!hasPendingToolCall` — pending tool call means the
320
+ // sub-agent is waiting on our runtime, not on the LLM API. When the
321
+ // caller opts in via messageStallThresholdMs (dispatch_stage passes
322
+ // escalating per-attempt values), the check returns session_gone with
323
+ // reason="message_stall" so the retry loop kicks in with a fresh
324
+ // sub-session. abort() is safe here (session is real, just stalled on
325
+ // the LLM API), so unlike status-absent session_gone we do NOT need
326
+ // skipSessionOps handling downstream.
327
+ //
328
+ // Busy-signal detection: `status?.type === "busy"` is the primary
329
+ // signal when the server's status map contains the sessionId. For
330
+ // worktree-CWD sessions the map is CWD-filtered and never contains the
331
+ // sessionId, so status is always absent — sessionAliveByMessages (loose
332
+ // liveness) plus a busy indicator derived from messages.length become
333
+ // equivalent.
334
+ const elapsedFromStart = now() - startTime;
335
+ const busyIndicated = status?.type === "busy" || (!status && messages.length > 0);
336
+ const stalledFromProgress = now() - lastProgressAt;
337
+ if (messageStallThresholdMs !== undefined &&
338
+ (sessionEverAppeared || sessionAliveByMessages) &&
339
+ busyIndicated &&
340
+ !hasPendingToolCall &&
341
+ stalledFromProgress >= messageStallThresholdMs) {
342
+ const hangMode = lastAssistant ? "mid_stream" : "bootstrap";
343
+ err(`[pollSubSession] MESSAGE_STALL session=${sessionId} polls=${pollCount} ` +
344
+ `mode=${hangMode} stalled_from_progress_ms=${stalledFromProgress} ` +
345
+ `threshold_ms=${messageStallThresholdMs} elapsed_ms=${elapsedFromStart} ` +
346
+ `messages=${messages.length} — LLM API hang suspected (no progress while status=busy)`);
347
+ await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
348
+ return {
349
+ kind: "session_gone",
350
+ polls: pollCount,
351
+ elapsedMs: elapsedFromStart,
352
+ reason: "message_stall",
353
+ };
354
+ }
355
+ const finishComplete = hasFinish && !hasPendingToolCall && properOrdering;
356
+ // INV-1: `!status` alone is not idle. Require `status.type === "idle"` AND
357
+ // that we saw the session in the status map at least once (or that the
358
+ // messages list already contains an assistant response).
359
+ const statusIdle = !!status && status.type === "idle";
360
+ // Content-stable third-arm completion (see contentStableCompletionMs docs
361
+ // on PollOptions). Fires only when the sub-agent has produced at least
362
+ // one assistant message, has no pending tool_call, and has not advanced
363
+ // its content for the configured threshold. Prevents SESSION_GONE /
364
+ // MESSAGE_STALL false-positives on providers/sessions where neither
365
+ // statusIdle nor finishComplete ever fires (worktree-CWD, no finish field).
366
+ const contentStable = contentStableCompletionMs !== undefined &&
367
+ hasProducedAssistantMessage &&
368
+ !hasPendingToolCall &&
369
+ stalledFromProgress >= contentStableCompletionMs;
370
+ const looksComplete = statusIdle || finishComplete || contentStable;
371
+ dbg?.(`[pollSubSession] POLL session=${sessionId} poll=${pollCount} status=${status?.type ?? "absent"} ` +
372
+ `messages=${messages.length} finishComplete=${finishComplete} statusIdle=${statusIdle} ` +
373
+ `contentStable=${contentStable} sessionEverAppeared=${sessionEverAppeared}`);
374
+ if (!nudged && options.nudgeAtFraction != null && options.onNudge) {
375
+ const elapsedMs = now() - startTime;
376
+ if (elapsedMs >= options.nudgeAtFraction * timeoutMs) {
377
+ // Guard: only NUDGE if the session appears alive (has messages or present in status).
378
+ // Prevents NotFoundError on orphaned/gone sessions where tmux pane persists but
379
+ // the OpenCode session was already deleted (bug #2: PROJ-40406 orphan scenario).
380
+ const sessionLooksAlive = sessionAliveByMessages || status !== undefined;
381
+ if (sessionLooksAlive) {
382
+ nudged = true;
383
+ dbg?.(`[pollSubSession] NUDGE session=${sessionId} elapsed=${elapsedMs}ms fraction=${options.nudgeAtFraction}`);
384
+ await options.onNudge(sessionId, elapsedMs).catch(() => undefined);
385
+ }
386
+ else {
387
+ dbg?.(`[pollSubSession] NUDGE_SKIP session=${sessionId} elapsed=${elapsedMs}ms ` +
388
+ `reason=session_looks_dead (no_messages=${messages.length === 0} no_status=${!status})`);
389
+ nudged = true; // Mark as nudged to prevent retry on next poll
390
+ }
391
+ }
392
+ }
393
+ if (!looksComplete)
394
+ continue;
395
+ // Completion signal fired. Require that we have observed the session at
396
+ // least once via status OR that at least one assistant message exists.
397
+ // This blocks the "!status on first poll before session registered" false
398
+ // positive that plagued v0.10.1 rollback.
399
+ if (!sessionEverAppeared && messages.length === 0) {
400
+ dbg?.(`[pollSubSession] IGNORE completion (session never appeared, no messages) session=${sessionId} poll=${pollCount}`);
401
+ continue;
402
+ }
403
+ if (!lastAssistant) {
404
+ return {
405
+ kind: "empty",
406
+ reason: "no assistant message",
407
+ polls: pollCount,
408
+ elapsedMs: now() - startTime,
409
+ };
410
+ }
411
+ const text = (lastAssistant.parts ?? [])
412
+ .filter(p => p.type === "text" && typeof p.text === "string" && p.text.length > 0)
413
+ .map(p => p.text)
414
+ .join("\n");
415
+ if (text.length > 0) {
416
+ if (preambleOnlyTextThreshold !== undefined &&
417
+ preambleOnlyTextThreshold > 0 &&
418
+ text.trim().length < preambleOnlyTextThreshold &&
419
+ !hasPendingToolCall) {
420
+ dbg?.(`[pollSubSession] preamble-only detected session=${sessionId} ` +
421
+ `textLen=${text.trim().length} threshold=${preambleOnlyTextThreshold} — ` +
422
+ `reclassifying text outcome as empty`);
423
+ return {
424
+ kind: "empty",
425
+ reason: "preamble_only",
426
+ polls: pollCount,
427
+ elapsedMs: now() - startTime,
428
+ };
429
+ }
430
+ return { kind: "text", text, polls: pollCount, elapsedMs: now() - startTime };
431
+ }
432
+ return {
433
+ kind: "empty",
434
+ reason: "assistant message has no text parts",
435
+ polls: pollCount,
436
+ elapsedMs: now() - startTime,
437
+ };
438
+ }
439
+ err(`[pollSubSession] TIMEOUT session=${sessionId} polls=${pollCount} transientFailures=${transientFailures}`);
440
+ await client.session.abort({ path: { id: sessionId } }).catch(() => undefined);
441
+ return {
442
+ kind: "timeout",
443
+ polls: pollCount,
444
+ elapsedMs: now() - startTime,
445
+ transientFailures,
446
+ };
447
+ }
448
+ /**
449
+ * Convert a {@link PollOutcome} into a display string plus a boolean success
450
+ * flag. Used by callers that need a single string for downstream serialization
451
+ * (e.g., embedding in a JSON response) but must also know whether the
452
+ * sub-agent actually produced work.
453
+ */
454
+ export function pollOutcomeToLegacy(outcome) {
455
+ switch (outcome.kind) {
456
+ case "text":
457
+ return { text: outcome.text, success: true };
458
+ case "empty":
459
+ return {
460
+ text: `(session complete, no text output — ${outcome.reason})`,
461
+ success: false,
462
+ };
463
+ case "timeout":
464
+ return {
465
+ text: `(timeout: sub-agent did not complete within the allotted time; ` +
466
+ `polls=${outcome.polls} transientFailures=${outcome.transientFailures})`,
467
+ success: false,
468
+ };
469
+ case "aborted":
470
+ return { text: `(aborted: ${outcome.reason})`, success: false };
471
+ case "permission_stall":
472
+ return {
473
+ text: outcome.permissionType
474
+ ? `(permission_stall: sub-agent blocked on ${outcome.permissionType} permission (id=${outcome.permissionID}) — auto-rejected and aborted)`
475
+ : `(permission_stall: sub-agent tool call stalled for ${outcome.stalledMs}ms — likely waiting for external_directory permission approval that cannot be answered in subagent context)`,
476
+ success: false,
477
+ };
478
+ case "session_gone":
479
+ return {
480
+ text: outcome.reason === "message_stall"
481
+ ? `(session_gone[message_stall]: sub-session busy for ${outcome.elapsedMs}ms without producing any assistant message ` +
482
+ `(polls=${outcome.polls}) — LLM API hang suspected. Caller should redispatch a new session.)`
483
+ : `(session_gone: sub-session disappeared from status map after appearing, no new messages, ` +
484
+ `and no recent tool activity for at least the configured grace period ` +
485
+ `(polls=${outcome.polls}, elapsed=${outcome.elapsedMs}ms). ` +
486
+ `Caller should redispatch a new session instead of retrying this one.)`,
487
+ success: false,
488
+ };
489
+ default: {
490
+ const _exhaustive = outcome;
491
+ throw new Error(`Unhandled outcome kind: ${_exhaustive.kind}`);
492
+ }
493
+ }
494
+ }
@@ -0,0 +1,3 @@
1
+ export declare const REDACTED = "***REDACTED***";
2
+ export declare function redactSecrets(input: string): string;
3
+ export declare function redactAndTruncate(input: string, maxChars: number): string;