@simonepri/refined-antigravity-acp 0.1.1 → 0.2.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.
@@ -0,0 +1,286 @@
1
+ export const HARNESS_TAGS = [
2
+ "system_message",
3
+ "system_notification",
4
+ "system_information",
5
+ "system_instruction",
6
+ "task_notification",
7
+ "task_output",
8
+ "context",
9
+ "messaging",
10
+ "receive_notifications_directly",
11
+ "wait_for_task",
12
+ "additional_metadata",
13
+ "user_request",
14
+ "user_information",
15
+ "scratchpad",
16
+ "identity",
17
+ "communication_style",
18
+ "mcp_servers",
19
+ "subagents",
20
+ "artifacts",
21
+ "ask_question",
22
+ ] as const;
23
+
24
+ const HARNESS_SET = new Set(HARNESS_TAGS.map((t) => t.toLowerCase()));
25
+
26
+ /**
27
+ * Marker prepended to the replacement prompt Paseo dispatches when the user steers
28
+ * a running turn. It tells the model the new text interrupts and supersedes the
29
+ * instruction it was working on. It is stripped again on every read path:
30
+ * assistant output (`sanitizeAssistantText`, `cleanStreamingChunk`) and SQLite
31
+ * history replay (`server/history.ts`), so it never reaches the Paseo timeline.
32
+ */
33
+ export const STEERING_PREFIX = "[Mid-turn update]: ";
34
+
35
+ const STEERING_PREFIX_PATTERN = /(?:^|\n)\s*\[Mid-turn update\]:\s*/gi;
36
+
37
+ /** Removes any echoed steering marker from text that is about to be displayed. */
38
+ export function stripSteeringPrefix(text: string): string {
39
+ return text.replace(STEERING_PREFIX_PATTERN, "");
40
+ }
41
+
42
+ function isHarnessTag(tagName: string): boolean {
43
+ return HARNESS_SET.has(tagName.toLowerCase());
44
+ }
45
+
46
+ function isPossibleHarnessPrefix(str: string): boolean {
47
+ const clean = str.replace(/^<\/?/, "").toLowerCase();
48
+ if (clean === "") return true;
49
+ if (/^[^\sa-zA-Z0-9_-]/.test(clean)) return false;
50
+ return HARNESS_TAGS.some((t) => t.toLowerCase().startsWith(clean));
51
+ }
52
+
53
+ const SYSTEM_MSG_INTRO = "The following is a <SYSTEM_MESSAGE> not actually sent by the user";
54
+
55
+ function checkBannerPartialPrefix(input: string): number | null {
56
+ const minPrefixLen = 15;
57
+ for (let len = Math.min(input.length, SYSTEM_MSG_INTRO.length); len >= minPrefixLen; len--) {
58
+ const candidate = input.slice(input.length - len);
59
+ if (SYSTEM_MSG_INTRO.toLowerCase().startsWith(candidate.toLowerCase())) {
60
+ return input.length - len;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function cleanStreamingChunk(text: string): string {
67
+ return text
68
+ .replace(
69
+ /(?:\b(?:The\s+request\s+was\s+cancell?ed\s+by\s+the\s+client|context\s+cancell?ed)\b\.?)/gi,
70
+ "",
71
+ )
72
+ .replace(STEERING_PREFIX_PATTERN, "")
73
+ .replace(/\$\s*\\(?:to|rightarrow)\s*\$/gi, "→")
74
+ .replace(/\$\s*\\leftarrow\s*\$/gi, "←")
75
+ .replace(/\$\s*\\(?:implies|Rightarrow)\s*\$/gi, "⇒")
76
+ .replace(/\$\s*\\leftrightarrow\s*\$/gi, "↔")
77
+ .replace(/\$\s*\\le(?:q)?\s*\$/gi, "≤")
78
+ .replace(/\$\s*\\ge(?:q)?\s*\$/gi, "≥")
79
+ .replace(/\$\s*\\ne(?:q)?\s*\$/gi, "≠")
80
+ .replace(/\$\s*\\approx\s*\$/gi, "≈")
81
+ .replace(/\$\s*\\times\s*\$/gi, "×")
82
+ .replace(/\$\s*\\pm\s*\$/gi, "±")
83
+ .replace(/\$\s*\\infty\s*\$/gi, "∞")
84
+ .replace(/\\(?:to|rightarrow)\b/gi, "→")
85
+ .replace(/\\leftarrow\b/gi, "←")
86
+ .replace(/<kbd>(.*?)<\/kbd>/gi, "`$1`")
87
+ .replace(/<u>(.*?)<\/u>/gi, "*$1*")
88
+ .replace(/<mark>(.*?)<\/mark>/gi, "**$1**")
89
+ .replace(/<sup>(.*?)<\/sup>/gi, "^($1)");
90
+ }
91
+
92
+ export class StreamSanitizer {
93
+ private inHarnessTag = false;
94
+ private buffer = "";
95
+
96
+ reset(): void {
97
+ this.inHarnessTag = false;
98
+ this.buffer = "";
99
+ }
100
+
101
+ private processInHarness(input: string): string {
102
+ const closeMatch = input.match(/<\/\s*([a-zA-Z0-9_-]+)\s*>/i);
103
+ if (closeMatch && closeMatch.index !== undefined) {
104
+ const tagName = closeMatch[1].toLowerCase();
105
+ const endIdx = closeMatch.index + closeMatch[0].length;
106
+ if (isHarnessTag(tagName)) {
107
+ this.inHarnessTag = false;
108
+ }
109
+ return input.slice(endIdx);
110
+ }
111
+
112
+ const lastLt = input.lastIndexOf("<");
113
+ if (lastLt !== -1 && lastLt >= input.length - 40) {
114
+ const candidate = input.slice(lastLt);
115
+ if (isPossibleHarnessPrefix(candidate)) {
116
+ this.buffer = candidate;
117
+ return "";
118
+ }
119
+ }
120
+ return "";
121
+ }
122
+
123
+ private handleFullTag(input: string, gtIdx: number): { text: string; remainder: string } {
124
+ const fullTag = input.slice(0, gtIdx + 1);
125
+ const match = fullTag.match(/^<\s*(\/?)\s*([a-zA-Z0-9_-]+)/);
126
+ if (match) {
127
+ const isClosing = match[1] === "/";
128
+ const tagName = match[2].toLowerCase();
129
+ if (isHarnessTag(tagName)) {
130
+ if (!isClosing) this.inHarnessTag = true;
131
+ return { text: "", remainder: input.slice(gtIdx + 1) };
132
+ }
133
+ }
134
+ return { text: input[0], remainder: input.slice(1) };
135
+ }
136
+
137
+ process(chunk: string): string {
138
+ let input = this.buffer + chunk;
139
+ this.buffer = "";
140
+ let output = "";
141
+
142
+ while (input.length > 0) {
143
+ if (this.inHarnessTag) {
144
+ input = this.processInHarness(input);
145
+ continue;
146
+ }
147
+
148
+ const sysMatch = input.match(
149
+ /The following is a <SYSTEM_MESSAGE> not actually sent by the user[^\n]*\n?/i,
150
+ );
151
+ if (sysMatch && sysMatch.index !== undefined) {
152
+ output += input.slice(0, sysMatch.index);
153
+ input = input.slice(sysMatch.index + sysMatch[0].length);
154
+ this.inHarnessTag = true;
155
+ continue;
156
+ }
157
+
158
+ const bannerPrefixIdx = checkBannerPartialPrefix(input);
159
+ if (bannerPrefixIdx !== null) {
160
+ output += input.slice(0, bannerPrefixIdx);
161
+ this.buffer = input.slice(bannerPrefixIdx);
162
+ break;
163
+ }
164
+
165
+ const ltIdx = input.indexOf("<");
166
+ if (ltIdx === -1) {
167
+ output += input;
168
+ break;
169
+ }
170
+
171
+ if (ltIdx > 0) {
172
+ output += input.slice(0, ltIdx);
173
+ input = input.slice(ltIdx);
174
+ }
175
+
176
+ const gtIdx = input.indexOf(">");
177
+ if (gtIdx === -1) {
178
+ if (isPossibleHarnessPrefix(input)) {
179
+ this.buffer = input;
180
+ break;
181
+ }
182
+ output += input[0];
183
+ input = input.slice(1);
184
+ continue;
185
+ }
186
+
187
+ const res = this.handleFullTag(input, gtIdx);
188
+ output += res.text;
189
+ input = res.remainder;
190
+ }
191
+
192
+ return cleanStreamingChunk(output);
193
+ }
194
+
195
+ flush(): string {
196
+ const remaining = this.buffer;
197
+ this.buffer = "";
198
+ if (this.inHarnessTag) return "";
199
+ return cleanStreamingChunk(remaining);
200
+ }
201
+ }
202
+
203
+ export function sanitizeAssistantText(text: string): string {
204
+ const harnessPattern = HARNESS_TAGS.join("|");
205
+ return (
206
+ text
207
+ // Strip leaked internal harness tags & system notifications (both paired and streaming/unclosed)
208
+ .replace(
209
+ /The following is a <SYSTEM_MESSAGE> not actually sent by the user[\s\S]*?<\/SYSTEM_MESSAGE>/gi,
210
+ "",
211
+ )
212
+ .replace(
213
+ new RegExp(
214
+ `<(?:${harnessPattern})>(?:[\\s\\S]*?<\\/(?:${harnessPattern})>|[\\s\\S]*$)`,
215
+ "gi",
216
+ ),
217
+ "",
218
+ )
219
+ // Strip stray unclosed/opened harness tags if any remain
220
+ .replace(new RegExp(`<\\/?(?:${harnessPattern})>`, "gi"), "")
221
+ // Strip client cancellation error messages emitted during turn abort / steering
222
+ .replace(
223
+ /(?:\b(?:The\s+request\s+was\s+cancell?ed\s+by\s+the\s+client|context\s+cancell?ed)\b\.?)/gi,
224
+ "",
225
+ )
226
+ // Strip any echoed mid-turn update prefixes
227
+ .replace(STEERING_PREFIX_PATTERN, "")
228
+ // Convert common LaTeX math escapes to Unicode (Paseo does not render KaTeX/LaTeX)
229
+ .replace(/\$\s*\\(?:to|rightarrow)\s*\$/gi, "→")
230
+ .replace(/\$\s*\\leftarrow\s*\$/gi, "←")
231
+ .replace(/\$\s*\\(?:implies|Rightarrow)\s*\$/gi, "⇒")
232
+ .replace(/\$\s*\\leftrightarrow\s*\$/gi, "↔")
233
+ .replace(/\$\s*\\le(?:q)?\s*\$/gi, "≤")
234
+ .replace(/\$\s*\\ge(?:q)?\s*\$/gi, "≥")
235
+ .replace(/\$\s*\\ne(?:q)?\s*\$/gi, "≠")
236
+ .replace(/\$\s*\\approx\s*\$/gi, "≈")
237
+ .replace(/\$\s*\\times\s*\$/gi, "×")
238
+ .replace(/\$\s*\\pm\s*\$/gi, "±")
239
+ .replace(/\$\s*\\infty\s*\$/gi, "∞")
240
+ .replace(/\\(?:to|rightarrow)\b/gi, "→")
241
+ .replace(/\\leftarrow\b/gi, "←")
242
+ // Normalize keyboard keys for Paseo
243
+ .replace(/<kbd>(.*?)<\/kbd>/gi, "`$1`")
244
+ // Normalize basic styling tags that Paseo markdown parser does not wrap
245
+ .replace(/<u>(.*?)<\/u>/gi, "*$1*")
246
+ .replace(/<mark>(.*?)<\/mark>/gi, "**$1**")
247
+ .replace(/<sup>(.*?)<\/sup>/gi, "^($1)")
248
+ .trim()
249
+ );
250
+ }
251
+
252
+ export function formatSystemContext(systemPrompt?: string): string {
253
+ const parts: string[] = [];
254
+
255
+ if (systemPrompt && systemPrompt.trim().length > 0) {
256
+ parts.push(`[System Context]:\n${systemPrompt.trim()}`);
257
+ }
258
+
259
+ parts.push(
260
+ `[Formatting Guidance]:
261
+ - Math: The UI does not render LaTeX ($...$, $$...$$, \\to, \\frac). Use plain text or Unicode formulas (e.g. x^2, a/b, →, √, ∑).
262
+ - Mermaid: Always wrap both node labels AND edge labels containing spaces, punctuation, slashes, or parentheses in double quotes (e.g. id["Label (Details)"] and -->|"Label (Details)"|). Never put unquoted parentheses inside edge pipes |...| or node shapes.
263
+ - Keys & Markup: Use markdown backticks (\`Cmd\`, \`Ctrl+C\`) instead of HTML <kbd> tags. Never output internal tags like <system_message>.`,
264
+ );
265
+
266
+ return parts.join("\n\n");
267
+ }
268
+
269
+ /**
270
+ * Formats the replacement prompt for a steered turn, marking it as a mid-turn
271
+ * interruption so the model treats it as superseding the instruction in flight.
272
+ */
273
+ const SYSTEM_CONTEXT_PATTERN =
274
+ /(?:\[System Context\]:[\s\S]*?)?\[Formatting Guidance\]:[\s\S]*?(?=\n\n|$)\n*/g;
275
+
276
+ /**
277
+ * Removes injected system context and formatting guidance from replayed user text so it
278
+ * does not clutter the Paseo timeline.
279
+ */
280
+ export function stripSystemContext(text: string): string {
281
+ return text.replace(SYSTEM_CONTEXT_PATTERN, "");
282
+ }
283
+
284
+ export function formatSteeringPrompt(userText: string): string {
285
+ return `${STEERING_PREFIX}${userText.trim()}`;
286
+ }
@@ -0,0 +1,183 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export interface AvailableCommand {
6
+ name: string;
7
+ description: string;
8
+ }
9
+
10
+ export interface DiscoveredSkill {
11
+ name: string;
12
+ description: string;
13
+ instructions: string;
14
+ userInvocable: boolean;
15
+ filePath: string;
16
+ }
17
+
18
+ function unquote(val: string): string {
19
+ const trimmed = val.trim();
20
+ if (
21
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
22
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
23
+ ) {
24
+ return trimmed.slice(1, -1).replace(/''/g, "'");
25
+ }
26
+ const commentIdx = trimmed.search(/\s+#/);
27
+ return commentIdx !== -1 ? trimmed.slice(0, commentIdx).trim() : trimmed;
28
+ }
29
+
30
+ function parseInvocable(val: string): boolean {
31
+ const v = unquote(val).toLowerCase();
32
+ return v !== "false" && v !== "no" && v !== "0";
33
+ }
34
+
35
+ function parseDescription(
36
+ rest: string,
37
+ lines: string[],
38
+ i: number,
39
+ ): { desc: string; nextIndex: number } {
40
+ if (rest === ">" || rest === ">-" || rest === "|" || rest === "|-") {
41
+ const block: string[] = [];
42
+ let idx = i + 1;
43
+ while (idx < lines.length && (/^\s+/.test(lines[idx]) || lines[idx].trim().length === 0)) {
44
+ block.push(lines[idx].trim());
45
+ idx++;
46
+ }
47
+ const text = rest.startsWith(">") ? block.join(" ") : block.join("\n").trim();
48
+ return { desc: text, nextIndex: idx - 1 };
49
+ }
50
+ return { desc: unquote(rest), nextIndex: i };
51
+ }
52
+
53
+ function parseFrontmatter(frontmatter: string): {
54
+ name?: string;
55
+ description?: string;
56
+ userInvocable: boolean;
57
+ } {
58
+ const lines = frontmatter.split(/\r?\n/);
59
+ let name: string | undefined;
60
+ let description: string | undefined;
61
+ let userInvocable = true;
62
+
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const line = lines[i];
65
+ const nameMatch = line.match(/^name:\s*(.*)$/);
66
+ if (nameMatch) name = unquote(nameMatch[1]);
67
+
68
+ const invocableMatch = line.match(/^(?:user-invocable|user_invocable):\s*(.*)$/);
69
+ if (invocableMatch) userInvocable = parseInvocable(invocableMatch[1]);
70
+
71
+ const descMatch = line.match(/^description:\s*(.*)$/);
72
+ if (descMatch) {
73
+ const parsed = parseDescription(descMatch[1].trim(), lines, i);
74
+ description = parsed.desc;
75
+ i = parsed.nextIndex;
76
+ }
77
+ }
78
+ return { name, description, userInvocable };
79
+ }
80
+
81
+ export function parseSkillFile(filePath: string): DiscoveredSkill | null {
82
+ if (!existsSync(filePath)) return null;
83
+ try {
84
+ const content = readFileSync(filePath, "utf-8");
85
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)(?:\r?\n|$)([\s\S]*)$/);
86
+ const meta = fmMatch ? parseFrontmatter(fmMatch[1]) : { userInvocable: true };
87
+ const instructions = fmMatch ? fmMatch[2].trim() : content;
88
+ const fallbackName = path.basename(path.dirname(filePath));
89
+
90
+ return {
91
+ name: meta.name && meta.name.length > 0 ? meta.name : fallbackName,
92
+ description:
93
+ meta.description && meta.description.length > 0
94
+ ? meta.description
95
+ : `Skill: ${fallbackName}`,
96
+ instructions,
97
+ userInvocable: meta.userInvocable,
98
+ filePath,
99
+ };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ export function resolveSkillRoots(cwd?: string): string[] {
106
+ const candidates: string[] = [];
107
+ if (cwd) {
108
+ candidates.push(
109
+ path.join(cwd, ".agents/skills"),
110
+ path.join(cwd, ".gemini/skills"),
111
+ path.join(cwd, ".codex/skills"),
112
+ path.join(cwd, "skills"),
113
+ );
114
+ }
115
+ const home = os.homedir();
116
+ candidates.push(
117
+ path.join(home, ".gemini/antigravity-cli/skills"),
118
+ path.join(home, ".gemini/config/skills"),
119
+ path.join(home, ".gemini/skills"),
120
+ path.join(home, ".agents/skills"),
121
+ path.join(home, ".claude/skills"),
122
+ path.join(home, ".codex/skills"),
123
+ );
124
+ return Array.from(new Set(candidates.filter((dir) => existsSync(dir))));
125
+ }
126
+
127
+ export function discoverSkills(cwd?: string): Map<string, DiscoveredSkill> {
128
+ const roots = resolveSkillRoots(cwd);
129
+ const skills = new Map<string, DiscoveredSkill>();
130
+
131
+ for (const root of roots) {
132
+ try {
133
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
134
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
135
+ const skill = parseSkillFile(path.join(root, entry.name, "SKILL.md"));
136
+ if (skill && !skills.has(skill.name)) {
137
+ skills.set(skill.name, skill);
138
+ }
139
+ }
140
+ } catch {
141
+ // Ignore unreadable dirs
142
+ }
143
+ }
144
+ return skills;
145
+ }
146
+
147
+ export function augmentCommands(
148
+ existingCommands: AvailableCommand[] | undefined,
149
+ cwd?: string,
150
+ ): AvailableCommand[] {
151
+ const result = new Map<string, AvailableCommand>();
152
+ if (Array.isArray(existingCommands)) {
153
+ for (const cmd of existingCommands) {
154
+ if (cmd?.name) result.set(cmd.name, cmd);
155
+ }
156
+ }
157
+ for (const skill of discoverSkills(cwd).values()) {
158
+ if (skill.userInvocable && !result.has(skill.name)) {
159
+ result.set(skill.name, { name: skill.name, description: skill.description });
160
+ }
161
+ }
162
+ return Array.from(result.values());
163
+ }
164
+
165
+ export function expandSkillInvocation(text: string, cwd?: string): string {
166
+ const trimmed = text.trim();
167
+ if (!trimmed.startsWith("/")) return text;
168
+ const match = trimmed.match(/^\/([a-zA-Z0-9_-]+)(?:\s+([\s\S]*))?$/);
169
+ if (!match) return text;
170
+
171
+ const [_, commandName, rawArgs] = match;
172
+ const args = rawArgs ?? "";
173
+ const skill = discoverSkills(cwd).get(commandName);
174
+ if (!skill) return text;
175
+
176
+ let expanded = skill.instructions;
177
+ if (expanded.includes("$ARGUMENTS")) {
178
+ expanded = expanded.replaceAll("$ARGUMENTS", args);
179
+ } else if (args.trim().length > 0) {
180
+ expanded = `${expanded}\n\n**Arguments:** ${args}`;
181
+ }
182
+ return `[Skill: ${skill.name}]\n${expanded}\n[/Skill: ${skill.name}]`;
183
+ }
@@ -0,0 +1,199 @@
1
+ import type {
2
+ ProviderConnectRequest,
3
+ ProviderConnection,
4
+ ProviderEvent,
5
+ ProviderInput,
6
+ } from "@getpaseo/plugin/server/provider";
7
+ import { readConversationHistory } from "./history.js";
8
+ import { formatSteeringPrompt } from "./sanitize.js";
9
+ import { augmentCommands, expandSkillInvocation } from "./skills.js";
10
+
11
+ function transformPromptInput(
12
+ input: ProviderInput,
13
+ isSteerReplacement: boolean,
14
+ cwd?: string,
15
+ ): ProviderInput {
16
+ if (input.type !== "session.prompt" || input.prompt.input.type !== "message") {
17
+ return input;
18
+ }
19
+ // Only the first text part carries the steering marker; prefixing every part would
20
+ // repeat it throughout a multi-part prompt.
21
+ let markerPending = isSteerReplacement;
22
+ const content = input.prompt.input.content.map((part) => {
23
+ if (part.type !== "text") return part;
24
+ let text = expandSkillInvocation(part.text, cwd);
25
+ if (markerPending) {
26
+ text = formatSteeringPrompt(text);
27
+ markerPending = false;
28
+ }
29
+ return { ...part, text };
30
+ });
31
+ return {
32
+ ...input,
33
+ prompt: {
34
+ ...input.prompt,
35
+ input: { ...input.prompt.input, content },
36
+ },
37
+ };
38
+ }
39
+
40
+ function readNativeSessionId(persistence: unknown): string | null {
41
+ if (!persistence || typeof persistence !== "object" || Array.isArray(persistence)) {
42
+ return null;
43
+ }
44
+ const data = (persistence as { data?: unknown }).data;
45
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
46
+ const sessionId = (data as { sessionId?: unknown }).sessionId;
47
+ return typeof sessionId === "string" ? sessionId : null;
48
+ }
49
+
50
+ /**
51
+ * Connection wrapper that decorates a base ProviderConnection with:
52
+ * 1. Mid-turn steering (`prompt.steer`)
53
+ * 2. Slash skill command advertising (`session.commands`)
54
+ * 3. Slash skill expansion on prompt submission
55
+ * 4. Automatic conversation history replay from Antigravity SQLite on session reload
56
+ */
57
+ export class SteeringConnection implements ProviderConnection {
58
+ readonly version: number;
59
+ readonly capabilities: readonly string[];
60
+
61
+ private readonly sessionCwds = new Map<string, string>();
62
+ private readonly pendingSteers = new Set<string>();
63
+ private readonly replaySessions = new Set<string>();
64
+ private readonly listeners = new Set<(event: ProviderEvent) => void>();
65
+ private readonly unbindBase: () => void;
66
+
67
+ constructor(
68
+ private readonly base: ProviderConnection,
69
+ private readonly request: ProviderConnectRequest,
70
+ ) {
71
+ this.version = base.version;
72
+ this.capabilities = request.capabilities.includes("prompt.steer")
73
+ ? Array.from(new Set([...base.capabilities, "prompt.steer"]))
74
+ : base.capabilities;
75
+
76
+ this.unbindBase = this.base.onEvent((event) => this.handleBaseEvent(event));
77
+ }
78
+
79
+ private emitSessionSkills(sessionId: string, cwd: string): void {
80
+ try {
81
+ const commands = augmentCommands(undefined, cwd);
82
+ if (commands.length > 0) {
83
+ this.emit({ type: "session.commands", sessionId, commands });
84
+ }
85
+ } catch (err) {
86
+ console.error("[paseo-antigravity] Failed to emit session.commands on session.opened:", err);
87
+ }
88
+ }
89
+
90
+ private replaySessionHistory(sessionId: string, nativeSessionId: string): void {
91
+ try {
92
+ const history = readConversationHistory(nativeSessionId);
93
+ for (const entry of history) {
94
+ this.emit({
95
+ type: "timeline.item",
96
+ sessionId,
97
+ item: entry.item,
98
+ timestamp: entry.timestamp,
99
+ });
100
+ }
101
+ } catch (err) {
102
+ console.error(`[paseo-antigravity] Failed to replay history for ${sessionId}:`, err);
103
+ }
104
+ }
105
+
106
+ private handleSessionOpened(event: Extract<ProviderEvent, { type: "session.opened" }>): void {
107
+ const cwd = (event as { cwd?: string }).cwd ?? process.cwd();
108
+ this.sessionCwds.set(event.sessionId, cwd);
109
+
110
+ const sessionCaps = this.request.capabilities.includes("prompt.steer")
111
+ ? Array.from(new Set([...event.capabilities, "prompt.steer"]))
112
+ : event.capabilities;
113
+ this.emit({ ...event, capabilities: sessionCaps });
114
+
115
+ if (this.replaySessions.delete(event.sessionId)) {
116
+ const nativeId = readNativeSessionId(event.persistence);
117
+ if (nativeId) {
118
+ this.replaySessionHistory(event.sessionId, nativeId);
119
+ }
120
+ }
121
+
122
+ this.emitSessionSkills(event.sessionId, cwd);
123
+ }
124
+
125
+ private handleBaseEvent(event: ProviderEvent): void {
126
+ if (event.type === "session.opened") {
127
+ this.handleSessionOpened(event);
128
+ return;
129
+ }
130
+ if (event.type === "session.commands") {
131
+ const cwd = this.sessionCwds.get(event.sessionId);
132
+ const commands = augmentCommands(event.commands, cwd);
133
+ this.emit({ ...event, commands });
134
+ return;
135
+ }
136
+ this.emit(event);
137
+ }
138
+
139
+ private emit(event: ProviderEvent): void {
140
+ for (const listener of this.listeners) {
141
+ listener(event);
142
+ }
143
+ }
144
+
145
+ private handleSteerPrompt(sessionId: string, clientMessageId: string): void {
146
+ this.pendingSteers.add(sessionId);
147
+ this.emit({
148
+ type: "session.prompt_result",
149
+ sessionId,
150
+ clientMessageId,
151
+ result: { type: "completed" },
152
+ });
153
+ }
154
+
155
+ async send(input: ProviderInput): Promise<void> {
156
+ if (input.type === "session.open") {
157
+ if (input.history === "replay") {
158
+ this.replaySessions.add(input.sessionId);
159
+ }
160
+ }
161
+
162
+ if (input.type === "session.prompt") {
163
+ if (input.prompt.delivery === "steer") {
164
+ this.handleSteerPrompt(input.sessionId, input.prompt.clientMessageId);
165
+ return;
166
+ }
167
+ // Paseo answers a steer by cancelling the turn and re-dispatching the text as a
168
+ // normal prompt. `pendingSteers` is how we recognise that replacement so it can be
169
+ // marked as a mid-turn interruption rather than a fresh instruction.
170
+ const isReplacement = this.pendingSteers.delete(input.sessionId);
171
+ const cwd = this.sessionCwds.get(input.sessionId);
172
+ input = transformPromptInput(input, isReplacement, cwd);
173
+ }
174
+
175
+ if (input.type === "session.close") {
176
+ this.pendingSteers.delete(input.sessionId);
177
+ this.replaySessions.delete(input.sessionId);
178
+ this.sessionCwds.delete(input.sessionId);
179
+ }
180
+
181
+ return this.base.send(input);
182
+ }
183
+
184
+ onEvent(listener: (event: ProviderEvent) => void): () => void {
185
+ this.listeners.add(listener);
186
+ return () => {
187
+ this.listeners.delete(listener);
188
+ };
189
+ }
190
+
191
+ async close(): Promise<void> {
192
+ this.pendingSteers.clear();
193
+ this.replaySessions.clear();
194
+ this.sessionCwds.clear();
195
+ this.listeners.clear();
196
+ this.unbindBase();
197
+ return this.base.close();
198
+ }
199
+ }
@@ -56,7 +56,6 @@ export declare class HangDetector {
56
56
  private onGraceTimerFired;
57
57
  private checkInactivity;
58
58
  private handleWaitingForTasks;
59
- private handleTurnCompleteCandidate;
60
59
  private clearGraceTimersForSession;
61
60
  processStderrLine(line: string): void;
62
61
  processOutbound(msg: AcpStreamMessage): void;