@henryqw/pi-herdr-tools 1.0.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.
- package/LICENSE +21 -0
- package/README.md +204 -0
- package/docs/btw-merge-sequence.html +156 -0
- package/docs/btw-merge-sequence.svg +138 -0
- package/docs/clone-flow.html +170 -0
- package/docs/clone-flow.svg +131 -0
- package/docs/done-flow.html +212 -0
- package/docs/done-flow.svg +166 -0
- package/docs/workflow-overview.html +114 -0
- package/docs/workflow-overview.svg +105 -0
- package/extensions/index.ts +12 -0
- package/internal/btw.ts +836 -0
- package/internal/clone-tab.ts +405 -0
- package/internal/config.ts +103 -0
- package/internal/context-store.ts +286 -0
- package/internal/core.ts +411 -0
- package/internal/done.ts +99 -0
- package/internal/merge.ts +317 -0
- package/internal/rename.ts +442 -0
- package/internal/router.ts +42 -0
- package/package.json +36 -0
package/internal/done.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { tmpdir } from "node:os";
|
|
2
|
+
import type { ExecResult, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createHerdrClient, withWorktreeLock } from "@henryqw/pi-herdr";
|
|
4
|
+
|
|
5
|
+
type SnapshotPane = { tab_id?: unknown; cwd?: unknown };
|
|
6
|
+
type TabEntry = { tab_id?: unknown; workspace_id?: unknown; label?: unknown };
|
|
7
|
+
|
|
8
|
+
export default function herdrDoneExtension(pi: ExtensionAPI): void {
|
|
9
|
+
const herdr = createHerdrClient<{ cwd: string }>(pi.exec.bind(pi));
|
|
10
|
+
|
|
11
|
+
const execOrThrow = async (command: string, args: string[], cwd: string): Promise<string> => {
|
|
12
|
+
const result: ExecResult = await pi.exec(command, args, { cwd });
|
|
13
|
+
if (result.code !== 0 || result.killed) {
|
|
14
|
+
throw new Error(`${command} ${args[0]} failed: ${result.stderr.trim() || "killed"}`);
|
|
15
|
+
}
|
|
16
|
+
return result.stdout;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
pi.registerCommand("done", {
|
|
20
|
+
description: "Remove the current Herdr worktree and close its workspace tabs",
|
|
21
|
+
handler: async (args, ctx) => {
|
|
22
|
+
const option = args.trim();
|
|
23
|
+
if (option && option !== "--force") throw new Error("Usage: /done [--force]");
|
|
24
|
+
if (process.env.HERDR_ENV !== "1") {
|
|
25
|
+
throw new Error("/done requires the current Pi session inside Herdr (HERDR_ENV=1).");
|
|
26
|
+
}
|
|
27
|
+
const tabId = process.env.HERDR_TAB_ID?.trim();
|
|
28
|
+
if (!tabId) throw new Error("HERDR_TAB_ID is missing.");
|
|
29
|
+
const workspaceId = process.env.HERDR_WORKSPACE_ID?.trim();
|
|
30
|
+
if (!workspaceId) throw new Error("HERDR_WORKSPACE_ID is missing.");
|
|
31
|
+
|
|
32
|
+
if (option !== "--force") {
|
|
33
|
+
const confirmed = await ctx.ui.confirm("Done", "Close its Herdr tabs and remove the current worktree?");
|
|
34
|
+
if (!confirmed) return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
await ctx.waitForIdle();
|
|
38
|
+
const checkout = (await execOrThrow("git", ["rev-parse", "--show-toplevel"], ctx.cwd)).trim();
|
|
39
|
+
// First record of the NUL-delimited list is always the main worktree;
|
|
40
|
+
// -z keeps paths containing newlines parseable.
|
|
41
|
+
const worktreeFields = (await execOrThrow("git", ["worktree", "list", "--porcelain", "-z"], checkout)).split("\0");
|
|
42
|
+
const mainCheckout = worktreeFields[0]?.startsWith("worktree ") ? worktreeFields[0].slice("worktree ".length) : undefined;
|
|
43
|
+
if (!mainCheckout) throw new Error("git worktree list returned no main worktree.");
|
|
44
|
+
// A bare primary has no working tree to pull into.
|
|
45
|
+
const parentIsBare = mainCheckout !== checkout &&
|
|
46
|
+
worktreeFields.slice(1, worktreeFields.indexOf("")).includes("bare");
|
|
47
|
+
|
|
48
|
+
const siblingTabIds = await withWorktreeLock(checkout, async () => {
|
|
49
|
+
// With --force, skip the dependents check entirely and let git remove the checkout.
|
|
50
|
+
let dependentIds: string[] = [];
|
|
51
|
+
if (option !== "--force") {
|
|
52
|
+
const snapshot = await herdr.json(["api", "snapshot"], { cwd: ctx.cwd });
|
|
53
|
+
const panes = (snapshot.result as { snapshot?: { panes?: SnapshotPane[] } } | undefined)?.snapshot?.panes;
|
|
54
|
+
if (!Array.isArray(panes)) throw new Error("herdr api snapshot returned no panes.");
|
|
55
|
+
dependentIds = [...new Set(panes
|
|
56
|
+
.filter((pane): pane is SnapshotPane & { tab_id: string; cwd: string } =>
|
|
57
|
+
typeof pane.tab_id === "string" && pane.tab_id !== tabId && typeof pane.cwd === "string" &&
|
|
58
|
+
(pane.cwd === checkout || pane.cwd.startsWith(`${checkout}/`)))
|
|
59
|
+
.map((pane) => pane.tab_id))];
|
|
60
|
+
}
|
|
61
|
+
const listing = await herdr.json(["tab", "list"], { cwd: ctx.cwd });
|
|
62
|
+
const tabs = (listing.result as { tabs?: TabEntry[] } | undefined)?.tabs;
|
|
63
|
+
if (!Array.isArray(tabs)) throw new Error("herdr tab list returned no tabs.");
|
|
64
|
+
const validTabs = tabs.filter((tab): tab is TabEntry & { tab_id: string } => typeof tab.tab_id === "string");
|
|
65
|
+
const tabsById = new Map(validTabs.map((tab) => [tab.tab_id, tab]));
|
|
66
|
+
if (tabsById.get(tabId)?.workspace_id !== workspaceId) {
|
|
67
|
+
throw new Error(`Herdr tab ${tabId} does not belong to workspace ${workspaceId}.`);
|
|
68
|
+
}
|
|
69
|
+
const siblingTabIds = validTabs
|
|
70
|
+
.filter((tab) => tab.tab_id !== tabId && tab.workspace_id === workspaceId)
|
|
71
|
+
.map((tab) => tab.tab_id);
|
|
72
|
+
const blockers = dependentIds.filter((id) => tabsById.get(id)?.workspace_id !== workspaceId);
|
|
73
|
+
if (blockers.length > 0) {
|
|
74
|
+
const names = blockers.map((id) => {
|
|
75
|
+
const label = tabsById.get(id)?.label;
|
|
76
|
+
return typeof label === "string" ? label : id;
|
|
77
|
+
});
|
|
78
|
+
throw new Error(`Worktree still used by Herdr tabs ${names.join(", ")}; close them first.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await execOrThrow("git", [
|
|
82
|
+
"worktree", "remove", ...(option === "--force" ? ["--force"] : []), checkout,
|
|
83
|
+
], ctx.cwd);
|
|
84
|
+
return siblingTabIds;
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await Promise.all(siblingTabIds.map((id) => herdr.run(["tab", "close", id], { cwd: tmpdir() })));
|
|
88
|
+
if (!parentIsBare && mainCheckout !== checkout) {
|
|
89
|
+
// Serialize concurrent completions pulling the same parent checkout.
|
|
90
|
+
// Run outside the removed checkout because its directory no longer exists.
|
|
91
|
+
await withWorktreeLock(mainCheckout, () => execOrThrow("git", ["pull", "--ff-only"], mainCheckout));
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
// Close this session's tab last, even if sibling cleanup or the parent pull fails.
|
|
95
|
+
await herdr.run(["tab", "close", tabId], { cwd: tmpdir() });
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { BtwPayload } from "./core.ts";
|
|
3
|
+
|
|
4
|
+
export const MERGE_PROTOCOL_VERSION = 2 as const;
|
|
5
|
+
export const MERGE_REQUEST_FILE = "merge-request.json";
|
|
6
|
+
export const MERGE_CUSTOM_TYPE = "pi-herdr-btw.merge";
|
|
7
|
+
export const MAX_SUMMARY_BYTES = 64 * 1024;
|
|
8
|
+
export const MAX_PROMPT_BYTES = 16 * 1024;
|
|
9
|
+
/** Transcript budget stays well under MAX_SUMMARY_BYTES for JSON overhead. */
|
|
10
|
+
export const MERGE_TRANSCRIPT_BUDGET_BYTES = 48 * 1024;
|
|
11
|
+
export const TRANSCRIPT_TRUNCATION_NOTE =
|
|
12
|
+
"[earlier side-thread turns omitted to fit the merge budget]";
|
|
13
|
+
|
|
14
|
+
export type MergeRequest = {
|
|
15
|
+
protocolVersion: typeof MERGE_PROTOCOL_VERSION;
|
|
16
|
+
requestId: string;
|
|
17
|
+
launchId: string;
|
|
18
|
+
parentSessionId: string;
|
|
19
|
+
capability: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
/** Trimmed, 1..64 KiB packaged side-thread transcript. */
|
|
22
|
+
summary: string;
|
|
23
|
+
/** Trimmed, 1..16 KiB user prompt the parent auto-submits after the merge. */
|
|
24
|
+
prompt: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function isMergeRequest(value: unknown): value is MergeRequest {
|
|
28
|
+
if (!value || typeof value !== "object") return false;
|
|
29
|
+
const request = value as Partial<MergeRequest>;
|
|
30
|
+
return (
|
|
31
|
+
request.protocolVersion === MERGE_PROTOCOL_VERSION &&
|
|
32
|
+
typeof request.requestId === "string" &&
|
|
33
|
+
request.requestId.length > 0 &&
|
|
34
|
+
typeof request.launchId === "string" &&
|
|
35
|
+
request.launchId.length > 0 &&
|
|
36
|
+
typeof request.parentSessionId === "string" &&
|
|
37
|
+
request.parentSessionId.length > 0 &&
|
|
38
|
+
typeof request.capability === "string" &&
|
|
39
|
+
request.capability.length >= 32 &&
|
|
40
|
+
typeof request.createdAt === "string" &&
|
|
41
|
+
typeof request.summary === "string" &&
|
|
42
|
+
isSummaryWithinBounds(request.summary) &&
|
|
43
|
+
typeof request.prompt === "string" &&
|
|
44
|
+
isPromptWithinBounds(request.prompt)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isSummaryWithinBounds(summary: string): boolean {
|
|
49
|
+
const trimmed = summary.trim();
|
|
50
|
+
return trimmed.length > 0 && Buffer.byteLength(trimmed, "utf8") <= MAX_SUMMARY_BYTES;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isPromptWithinBounds(prompt: string): boolean {
|
|
54
|
+
const trimmed = prompt.trim();
|
|
55
|
+
return trimmed.length > 0 && Buffer.byteLength(trimmed, "utf8") <= MAX_PROMPT_BYTES;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A merge request is trusted only when it echoes the exact launch identity,
|
|
60
|
+
* capability token, and parent session binding of its own launch payload.
|
|
61
|
+
*/
|
|
62
|
+
export function validateRequestAgainstPayload(
|
|
63
|
+
request: MergeRequest,
|
|
64
|
+
payload: BtwPayload,
|
|
65
|
+
): string | undefined {
|
|
66
|
+
if (request.launchId !== payload.launchId) return "launch ID mismatch";
|
|
67
|
+
if (request.capability !== payload.capability) return "capability mismatch";
|
|
68
|
+
if (request.parentSessionId !== payload.parentSessionId) return "parent session mismatch";
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildMergeMessageContent(summary: string): string {
|
|
73
|
+
return `Merged from /btw (side-thread transcript)\n\n<btw-merge>\n${summary.trim()}\n</btw-merge>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type EntryLike = {
|
|
77
|
+
type: string;
|
|
78
|
+
customType?: string;
|
|
79
|
+
details?: unknown;
|
|
80
|
+
message?: { role?: unknown; content?: unknown };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Deduplicate against an already-persisted merge custom message by requestId. */
|
|
84
|
+
export function hasMergedRequestId(entries: EntryLike[], requestId: string): boolean {
|
|
85
|
+
return entries.some(
|
|
86
|
+
(entry) =>
|
|
87
|
+
entry.type === "custom_message" &&
|
|
88
|
+
entry.customType === MERGE_CUSTOM_TYPE &&
|
|
89
|
+
!!entry.details &&
|
|
90
|
+
typeof entry.details === "object" &&
|
|
91
|
+
(entry.details as { requestId?: unknown }).requestId === requestId,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Detect durable evidence that this merge's user prompt was persisted. */
|
|
96
|
+
export function hasSubmittedPromptRequestId(
|
|
97
|
+
entries: EntryLike[],
|
|
98
|
+
requestId: string,
|
|
99
|
+
prompt: string,
|
|
100
|
+
): boolean {
|
|
101
|
+
const mergeIndex = entries.findIndex(
|
|
102
|
+
(entry) =>
|
|
103
|
+
entry.type === "custom_message" &&
|
|
104
|
+
entry.customType === MERGE_CUSTOM_TYPE &&
|
|
105
|
+
!!entry.details &&
|
|
106
|
+
typeof entry.details === "object" &&
|
|
107
|
+
(entry.details as { requestId?: unknown; prompt?: unknown }).requestId === requestId &&
|
|
108
|
+
(entry.details as { requestId?: unknown; prompt?: unknown }).prompt === prompt,
|
|
109
|
+
);
|
|
110
|
+
return mergeIndex >= 0 && entries.slice(mergeIndex + 1).some(
|
|
111
|
+
(entry) => entry.type === "message" && entry.message?.role === "user" && textOfTurn(entry.message.content) === prompt.trim(),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function textOfTurn(content: unknown): string {
|
|
116
|
+
if (typeof content === "string") return content.trim();
|
|
117
|
+
if (!Array.isArray(content)) return "";
|
|
118
|
+
return content
|
|
119
|
+
.filter(
|
|
120
|
+
(block): block is { type: "text"; text: string } =>
|
|
121
|
+
!!block && typeof block === "object" && block.type === "text" && typeof block.text === "string",
|
|
122
|
+
)
|
|
123
|
+
.map((block) => block.text)
|
|
124
|
+
.join("\n")
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Keep at most maxBytes of UTF-8 from the tail of the text. */
|
|
129
|
+
function tailBytes(text: string, maxBytes: number): string {
|
|
130
|
+
let sliced = text.slice(-maxBytes);
|
|
131
|
+
while (Buffer.byteLength(sliced, "utf8") > maxBytes) {
|
|
132
|
+
const excess = Buffer.byteLength(sliced, "utf8") - maxBytes;
|
|
133
|
+
sliced = sliced.slice(Math.max(1, Math.ceil(excess / 4)));
|
|
134
|
+
}
|
|
135
|
+
return sliced;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Package the child's own conversation (user/assistant text turns only, no
|
|
140
|
+
* tool payloads) as the merge transcript. When over budget, whole turns are
|
|
141
|
+
* dropped from the head so the most recent findings survive.
|
|
142
|
+
*/
|
|
143
|
+
export function buildMergeTranscript(
|
|
144
|
+
messages: AgentMessage[],
|
|
145
|
+
budgetBytes = MERGE_TRANSCRIPT_BUDGET_BYTES,
|
|
146
|
+
): string | undefined {
|
|
147
|
+
const turns: string[] = [];
|
|
148
|
+
for (const message of messages) {
|
|
149
|
+
const { role, content } = message as { role?: string; content?: unknown };
|
|
150
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
151
|
+
const text = textOfTurn(content);
|
|
152
|
+
if (text) turns.push(`${role === "user" ? "User" : "Assistant"}:\n${text}`);
|
|
153
|
+
}
|
|
154
|
+
if (turns.length === 0) return undefined;
|
|
155
|
+
|
|
156
|
+
const kept: string[] = [];
|
|
157
|
+
let used = 0;
|
|
158
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
159
|
+
const turn = turns[index] as string;
|
|
160
|
+
const bytes = Buffer.byteLength(turn, "utf8") + 2;
|
|
161
|
+
if (used + bytes > budgetBytes) {
|
|
162
|
+
if (kept.length === 0) {
|
|
163
|
+
// A single oversized turn keeps its tail (the latest content).
|
|
164
|
+
kept.unshift(`${TRANSCRIPT_TRUNCATION_NOTE}\n${tailBytes(turn, budgetBytes)}`);
|
|
165
|
+
} else {
|
|
166
|
+
kept.unshift(TRANSCRIPT_TRUNCATION_NOTE);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
kept.unshift(turn);
|
|
171
|
+
used += bytes;
|
|
172
|
+
}
|
|
173
|
+
return kept.join("\n\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type MergeStorePort = {
|
|
177
|
+
listLaunchPayloadPaths(): Promise<string[]>;
|
|
178
|
+
read(payloadPath: string): Promise<BtwPayload>;
|
|
179
|
+
readMergeRequest(payloadPath: string): Promise<unknown>;
|
|
180
|
+
remove(payloadPath: string): Promise<void>;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export type ParentSessionPort = {
|
|
184
|
+
getSessionId(): string;
|
|
185
|
+
isIdle(): boolean;
|
|
186
|
+
getBranch(): EntryLike[];
|
|
187
|
+
/** Check model and authentication before consuming a merge request. */
|
|
188
|
+
canSubmitPrompt(): Promise<boolean>;
|
|
189
|
+
sendMergeMessage(content: string, details: { requestId: string; launchId: string; prompt: string }): void;
|
|
190
|
+
/** Submit the merge prompt as a user message that triggers a model turn. */
|
|
191
|
+
submitPrompt(prompt: string): void;
|
|
192
|
+
notify(message: string, type: "info" | "warning" | "error"): void;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export type ScanResult = {
|
|
196
|
+
delivered: number;
|
|
197
|
+
deferred: number;
|
|
198
|
+
rejected: number;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parent-side merge coordinator. Scans the private launch store for pending
|
|
203
|
+
* merge requests bound to the current parent session and consumes each after
|
|
204
|
+
* delivery or rejection.
|
|
205
|
+
*/
|
|
206
|
+
export class MergeCoordinator {
|
|
207
|
+
private readonly store: MergeStorePort;
|
|
208
|
+
private readonly session: ParentSessionPort;
|
|
209
|
+
private scanning = false;
|
|
210
|
+
|
|
211
|
+
constructor(store: MergeStorePort, session: ParentSessionPort) {
|
|
212
|
+
this.store = store;
|
|
213
|
+
this.session = session;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async scan(): Promise<ScanResult> {
|
|
217
|
+
if (this.scanning) return { delivered: 0, deferred: 0, rejected: 0 };
|
|
218
|
+
this.scanning = true;
|
|
219
|
+
try {
|
|
220
|
+
return await this.scanOnce();
|
|
221
|
+
} finally {
|
|
222
|
+
this.scanning = false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async scanOnce(): Promise<ScanResult> {
|
|
227
|
+
const result: ScanResult = { delivered: 0, deferred: 0, rejected: 0 };
|
|
228
|
+
let payloadPaths: string[];
|
|
229
|
+
try {
|
|
230
|
+
payloadPaths = await this.store.listLaunchPayloadPaths();
|
|
231
|
+
} catch {
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const payloadPath of payloadPaths) {
|
|
236
|
+
if (result.delivered > 0) break;
|
|
237
|
+
try {
|
|
238
|
+
await this.processLaunch(payloadPath, result);
|
|
239
|
+
} catch {
|
|
240
|
+
// Unsafe or unreadable launch directories are skipped, never trusted.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async processLaunch(payloadPath: string, result: ScanResult): Promise<void> {
|
|
247
|
+
const rawRequest = await this.store.readMergeRequest(payloadPath);
|
|
248
|
+
if (rawRequest === undefined) return;
|
|
249
|
+
|
|
250
|
+
const payload = await this.store.read(payloadPath);
|
|
251
|
+
// Only the session a launch is bound to may consume its merge requests.
|
|
252
|
+
if (payload.parentSessionId !== this.session.getSessionId()) return;
|
|
253
|
+
|
|
254
|
+
if (!isMergeRequest(rawRequest)) {
|
|
255
|
+
await this.reject(payloadPath, "malformed merge request");
|
|
256
|
+
result.rejected += 1;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const validationError = validateRequestAgainstPayload(rawRequest, payload);
|
|
260
|
+
if (validationError) {
|
|
261
|
+
await this.reject(payloadPath, validationError);
|
|
262
|
+
result.rejected += 1;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const entries = this.session.getBranch();
|
|
267
|
+
if (hasSubmittedPromptRequestId(entries, rawRequest.requestId, rawRequest.prompt)) {
|
|
268
|
+
await this.store.remove(payloadPath);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// pi.sendUserMessage() is fire-and-forget. Keep the request pending when
|
|
273
|
+
// current model/auth state would reject its prompt.
|
|
274
|
+
try {
|
|
275
|
+
if (!(await this.session.canSubmitPrompt())) {
|
|
276
|
+
result.deferred += 1;
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
result.deferred += 1;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!this.session.isIdle()) {
|
|
285
|
+
// Never steer or queue a model turn mid-stream; retry on agent_settled.
|
|
286
|
+
result.deferred += 1;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Refresh branch evidence after authentication; branch can change while the
|
|
291
|
+
// asynchronous model check is pending.
|
|
292
|
+
const currentEntries = this.session.getBranch();
|
|
293
|
+
if (hasSubmittedPromptRequestId(currentEntries, rawRequest.requestId, rawRequest.prompt)) {
|
|
294
|
+
await this.store.remove(payloadPath);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Re-check the session binding immediately before appending.
|
|
299
|
+
if (payload.parentSessionId !== this.session.getSessionId()) return;
|
|
300
|
+
if (!hasMergedRequestId(currentEntries, rawRequest.requestId)) {
|
|
301
|
+
this.session.sendMergeMessage(buildMergeMessageContent(rawRequest.summary), {
|
|
302
|
+
requestId: rawRequest.requestId,
|
|
303
|
+
launchId: rawRequest.launchId,
|
|
304
|
+
prompt: rawRequest.prompt,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
this.session.submitPrompt(rawRequest.prompt);
|
|
308
|
+
result.delivered += 1;
|
|
309
|
+
await this.store.remove(payloadPath);
|
|
310
|
+
this.session.notify("Merged a /btw side thread into this session; continuing with its prompt.", "info");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private async reject(payloadPath: string, reason: string): Promise<void> {
|
|
314
|
+
await this.store.remove(payloadPath);
|
|
315
|
+
this.session.notify(`Rejected a /btw merge request: ${reason}`, "warning");
|
|
316
|
+
}
|
|
317
|
+
}
|