copilot-reverse 0.5.1 → 0.5.3

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.
@@ -86,13 +86,26 @@ export class CopilotAdapter {
86
86
  const data = (await res.json());
87
87
  const choice = data.choices[0];
88
88
  const content = [];
89
- if (choice.message.content)
90
- content.push({ type: "text", text: choice.message.content });
89
+ // Recover inline-XML tool calls in non-stream replies too (same reason as the stream path).
90
+ let xmlTool = false;
91
+ if (choice.message.content) {
92
+ const ex = new ToolCallExtractor();
93
+ for (const ev of [...ex.feed(choice.message.content), ...ex.flush()]) {
94
+ if (ev.kind === "text") {
95
+ if (ev.text)
96
+ content.push({ type: "text", text: ev.text });
97
+ }
98
+ else {
99
+ xmlTool = true;
100
+ content.push({ type: "tool_use", id: ev.tool.id, name: ev.tool.name, input: ev.tool.input });
101
+ }
102
+ }
103
+ }
91
104
  for (const tc of choice.message.tool_calls ?? [])
92
105
  content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: safeJson(tc.function.arguments) });
93
106
  return {
94
107
  id: data.id ?? `cmpl-${randomUUID().replace(/-/g, "")}`, model: req.model, content,
95
- finishReason: choice.finish_reason === "tool_calls" ? "tool_use" : choice.finish_reason === "length" ? "length" : "stop",
108
+ finishReason: choice.finish_reason === "tool_calls" || xmlTool ? "tool_use" : choice.finish_reason === "length" ? "length" : "stop",
96
109
  usage: { promptTokens: data.usage?.prompt_tokens ?? 0, completionTokens: data.usage?.completion_tokens ?? 0 },
97
110
  };
98
111
  }
@@ -134,9 +147,11 @@ export class CopilotAdapter {
134
147
  let usage;
135
148
  const mapFinish = (f) => f === "tool_calls" ? "tool_use" : f === "length" ? "length" : "stop";
136
149
  // Some models emit a tool call as inline XML text instead of native tool_calls (more likely on
137
- // long/tool-heavy turns). When the request has tools, route assistant text through an extractor
138
- // that recovers those blocks into structured tool calls; otherwise text passes straight through.
139
- const extractor = req.tools?.length ? new ToolCallExtractor() : undefined;
150
+ // long/tool-heavy turns) and they do it even when THIS request declared no tools (a follow-up
151
+ // turn, or a model that ignores the tools field). Always run assistant text through the extractor;
152
+ // it only captures on the distinctive `<invoke>`/`<function_calls>` sentinel and flushes anything
153
+ // unparseable back as text, so plain prose is unaffected.
154
+ const extractor = new ToolCallExtractor();
140
155
  let extractedTool = false;
141
156
  let extIdx = 100; // separate index space so recovered tools never collide with native tool_calls
142
157
  const toChunks = (events) => {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { ToolCallExtractor } from "../../core/tool-xml.js";
2
3
  // Outbound translation to GitHub Copilot's OpenAI Responses API. Newer Copilot models (e.g. gpt-5.5)
3
4
  // are served ONLY on /responses — their `supported_endpoints` omits /chat/completions — so the adapter
4
5
  // routes them here instead of the chat path. This is the mirror image of core/responses-inbound.ts
@@ -68,8 +69,20 @@ export function parseResponsesResult(data) {
68
69
  for (const item of data.output ?? []) {
69
70
  if (item.type === "message") {
70
71
  const text = (item.content ?? []).filter((p) => p.type === "output_text").map((p) => p.text ?? "").join("");
71
- if (text)
72
- content.push({ type: "text", text });
72
+ if (text) {
73
+ // Recover inline-XML tool calls here too (some models emit them as output_text).
74
+ const ex = new ToolCallExtractor();
75
+ for (const ev of [...ex.feed(text), ...ex.flush()]) {
76
+ if (ev.kind === "text") {
77
+ if (ev.text)
78
+ content.push({ type: "text", text: ev.text });
79
+ }
80
+ else {
81
+ sawTool = true;
82
+ content.push({ type: "tool_use", id: ev.tool.id, name: ev.tool.name, input: ev.tool.input });
83
+ }
84
+ }
85
+ }
73
86
  }
74
87
  else if (item.type === "function_call") {
75
88
  sawTool = true;
@@ -99,6 +112,25 @@ export async function* streamResponses(res) {
99
112
  let usage;
100
113
  const toolByOutputIndex = new Map(); // responses output_index -> canonical tool index
101
114
  let nextToolIndex = 0;
115
+ // Some models stream a tool call as inline XML text instead of a function_call item; recover it.
116
+ // Extracted tools use a high index space so they never collide with native function_call indices.
117
+ const extractor = new ToolCallExtractor();
118
+ let extIdx = 100, extractedTool = false;
119
+ const toChunks = (events) => {
120
+ const out = [];
121
+ for (const ev of events) {
122
+ if (ev.kind === "text") {
123
+ if (ev.text)
124
+ out.push({ kind: "text", delta: ev.text, done: false });
125
+ continue;
126
+ }
127
+ const index = extIdx++;
128
+ extractedTool = true;
129
+ out.push({ kind: "tool_use_start", index, id: ev.tool.id, name: ev.tool.name, done: false });
130
+ out.push({ kind: "tool_use_delta", index, argsDelta: JSON.stringify(ev.tool.input), done: false });
131
+ }
132
+ return out;
133
+ };
102
134
  const usageOf = (u) => u ? { promptTokens: u.input_tokens ?? 0, completionTokens: u.output_tokens ?? 0, cachedTokens: u.input_tokens_details?.cached_tokens ?? 0 } : undefined;
103
135
  for (;;) {
104
136
  const { value, done } = await reader.read();
@@ -133,7 +165,8 @@ export async function* streamResponses(res) {
133
165
  }
134
166
  case "response.output_text.delta":
135
167
  if (ev.delta)
136
- yield { kind: "text", delta: ev.delta, done: false };
168
+ for (const ch of toChunks(extractor.feed(ev.delta)))
169
+ yield ch;
137
170
  break;
138
171
  case "response.function_call_arguments.delta": {
139
172
  const idx = toolByOutputIndex.get(ev.output_index);
@@ -142,7 +175,7 @@ export async function* streamResponses(res) {
142
175
  break;
143
176
  }
144
177
  case "response.completed":
145
- if (toolByOutputIndex.size)
178
+ if (toolByOutputIndex.size || extractedTool)
146
179
  finishReason = "tool_use";
147
180
  usage = usageOf(ev.response?.usage) ?? usage;
148
181
  break;
@@ -157,5 +190,9 @@ export async function* streamResponses(res) {
157
190
  }
158
191
  }
159
192
  }
193
+ for (const ch of toChunks(extractor.flush()))
194
+ yield ch;
195
+ if (extractedTool && finishReason === "stop")
196
+ finishReason = "tool_use";
160
197
  yield { kind: "done", done: true, finishReason, usage };
161
198
  }
@@ -3,7 +3,7 @@ export function defaultConfig() {
3
3
  bindHost: "127.0.0.1",
4
4
  supervisorPort: 7890,
5
5
  workerPort: 7891,
6
- restart: { maxCrashes: 5, windowMs: 60_000, baseBackoffMs: 500, maxBackoffMs: 8_000 },
6
+ restart: { maxCrashes: 5, windowMs: 60_000, baseBackoffMs: 500, maxBackoffMs: 8_000, unhealthyCooldownMs: 30_000 },
7
7
  // Empty = pass the requested model straight through to Copilot. Add entries (or "*") to remap.
8
8
  modelMap: {},
9
9
  // Set MAESTRO_REPORT_REPO=owner/repo to override where /report files diagnostics issues.
@@ -10,6 +10,7 @@ import { dataDir, dbPath } from "../shared/paths.js";
10
10
  import { readGhToken } from "../shared/creds.js";
11
11
  import { probeGithubAuth } from "../providers/copilot/token.js";
12
12
  import { GithubHeartbeat, SIGNED_OUT_DETAIL } from "./github-heartbeat.js";
13
+ import { appendCrashLog } from "../shared/crash-log.js";
13
14
  export function startSupervisor() {
14
15
  const config = defaultConfig();
15
16
  mkdirSync(dataDir(), { recursive: true });
@@ -21,6 +22,9 @@ export function startSupervisor() {
21
22
  onStateChange: (s) => { state = s; bus.emit("state", { state: s }); },
22
23
  onCrash: (d, exitCode, stderrTail) => {
23
24
  recordRestart(db, { ts: Date.now(), reason: d.markedUnhealthy ? "unhealthy" : "crash", exitCode, stderrTail, backoffMs: d.backoffMs, markedUnhealthy: d.markedUnhealthy ? 1 : 0 });
25
+ // Also persist to crash.log so a worker crash is diagnosable post-mortem — the DB stderrTail can
26
+ // be empty if the worker died before flushing; this keeps whatever it did emit.
27
+ appendCrashLog("worker-crash", `exit=${exitCode} unhealthy=${d.markedUnhealthy} backoff=${d.backoffMs}ms\n${stderrTail || "(no stderr captured)"}`);
24
28
  bus.emit("crash", { exitCode, ...d });
25
29
  },
26
30
  onWorkerMessage: (m) => {
@@ -16,7 +16,7 @@ export class RestartController {
16
16
  const backoffMs = Math.min(this.policy.baseBackoffMs * 2 ** (this.consecutive - 1), this.policy.maxBackoffMs);
17
17
  return { backoffMs, markedUnhealthy: this.crashTimes.length >= this.policy.maxCrashes, crashesInWindow: this.crashTimes.length };
18
18
  }
19
- reset() { this.consecutive = 0; }
19
+ reset() { this.consecutive = 0; this.crashTimes = []; }
20
20
  }
21
21
  export class WorkerMonitor {
22
22
  config;
@@ -58,7 +58,14 @@ export class WorkerMonitor {
58
58
  const d = this.controller.onCrash();
59
59
  this.hooks.onCrash(d, code, this.stderrTail);
60
60
  if (d.markedUnhealthy) {
61
+ // Don't give up forever: a transient crash burst (token rotation, a flaky upstream) shouldn't
62
+ // leave the daemon permanently dead. Mark unhealthy, then after a cooldown reset the window and
63
+ // try once more — recovering on its own if the cause has passed.
61
64
  this.set("unhealthy");
65
+ setTimeout(() => { if (!this.stopped) {
66
+ this.controller.reset();
67
+ this.spawn();
68
+ } }, this.config.restart.unhealthyCooldownMs);
62
69
  return;
63
70
  }
64
71
  this.set("crashed");
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.mjs from package.json — do not edit.
2
- export const APP_VERSION = "0.5.1";
2
+ export const APP_VERSION = "0.5.3";
@@ -9,8 +9,13 @@ import { makeGatewayRunner } from "../core/server-tools.js";
9
9
  import { borrowSearch } from "../providers/copilot/borrow-search.js";
10
10
  import { dataDir } from "../shared/paths.js";
11
11
  import { defaultConfig } from "../shared/config.js";
12
- function send(msg) { if (process.send)
13
- process.send(msg); }
12
+ // Sending after the parent tore down the IPC channel throws ERR_IPC_CHANNEL_CLOSED; guard it so a
13
+ // crash-time report can't itself become a second, masking crash.
14
+ function send(msg) { try {
15
+ if (process.connected)
16
+ process.send?.(msg);
17
+ }
18
+ catch { /* channel gone */ } }
14
19
  const cfg = defaultConfig();
15
20
  const port = Number(process.env.WORKER_PORT ?? cfg.workerPort);
16
21
  const host = process.env.BIND_HOST ?? cfg.bindHost;
@@ -47,4 +52,18 @@ process.on("message", (m) => { if (m?.type === "shutdown") {
47
52
  clearInterval(hb);
48
53
  server.close(() => process.exit(0));
49
54
  } });
50
- process.on("uncaughtException", (e) => { send({ type: "error", message: e.message, stack: e.stack }); process.exit(1); });
55
+ // Crash diagnostics: write to STDERR FIRST so the supervisor's stderr capture (and crash.log) records
56
+ // the reason even when the IPC channel is already gone; the IPC send is a best-effort extra. Without
57
+ // the unhandledRejection handler, a stray floating rejection silently kills the worker on Node ≥15 —
58
+ // the exact "exit 1, empty stderr" crash loop we kept seeing.
59
+ function fatal(kind, e) {
60
+ const detail = e instanceof Error ? `${e.message}\n${e.stack ?? ""}` : String(e);
61
+ try {
62
+ process.stderr.write(`worker ${kind}: ${detail}\n`);
63
+ }
64
+ catch { /* nothing more we can do */ }
65
+ send({ type: "error", message: e instanceof Error ? e.message : String(e), stack: e instanceof Error ? e.stack : undefined });
66
+ process.exit(1);
67
+ }
68
+ process.on("uncaughtException", (e) => fatal("uncaughtException", e));
69
+ process.on("unhandledRejection", (e) => fatal("unhandledRejection", e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-reverse",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Interactive terminal app that exposes your GitHub Copilot subscription as local OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a built-in assistant.",
5
5
  "type": "module",
6
6
  "license": "MIT",