dsh-agy-link 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -2
  2. package/dist/index.js +515 -130
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -145,7 +145,8 @@ Bring **Google Antigravity models into DeepSeek Harness (DSH)** — chat, thinki
145
145
  | Capability | Description |
146
146
  | ---- | ---- |
147
147
  | 🔌 **A model route, not a proxy** | Registers the `antigravity` provider in DSH's model config; pick any Antigravity model (Gemini / Claude / GPT-OSS) from the `/model` picker |
148
- | 🌊 **Full streaming** | Text, thinking (reasoning), and agy tool activity (annotated reasoning blocks) mapped onto DSH's native chunk protocol |
148
+ | 🌊 **Full streaming** | Text, thinking (reasoning), and token usage mapped onto DSH's native chunk protocol |
149
+ | 🃏 **Native tool cards (v0.3)** | agy's tool activity renders with DSH's own tool-card UI — terminal cards for `run_command`, inline diffs for file writes — via the internal `agy_tool` mirror riding the real agent loop |
149
150
  | 🔗 **Session continuity** | Each DSH session binds to a native agy conversation (`--conversation`); multi-turn context rides agy history instead of re-sending everything |
150
151
  | 📊 **Token usage** | Input/output/thinking/cache tokens surface in DSH usage accounting |
151
152
  | ⚙️ **Settings status** | DSH Settings → Antigravity page shows agy connection/login/workspace/bindings/last-run state |
@@ -222,7 +223,7 @@ DSH-side tools and permissions are unaffected — this only governs what the spa
222
223
 
223
224
  ## 🧩 How it works
224
225
 
225
- One DSH model call = one short-lived `agy -p --output-format stream-json` process. The NDJSON event stream is parsed, normalized, and mapped to DSH StreamChunks: thinking → reasoning blocks, text → text blocks, tool activityannotated reasoning blocks, and the result envelope usage + finish. Conversation ids come from the stream itself, with a conversations-directory snapshot diff as fallback. **No reverse-engineered database scraping, no protobuf decoding, no token-file access** — only the official unmodified agy binary is spawned.
226
+ One DSH turn = one short-lived `agy -p --output-format stream-json` process. The NDJSON event stream is parsed, normalized, and recorded. Spans of that recording are mapped to DSH StreamChunks thinking → reasoning blocks, text → text blocks, result envelopeusage + finish and each **completed agy tool step cuts the span** with a `tool-calls` finish addressed to the internal `agy_tool` mirror. DSH's agent loop dispatches the mirror (it instantly replays the recorded output), writes real `tool/call` + `tool/result` session events, and re-calls the provider to continue the run — so tool activity renders with DSH's **native tool-card UI** (terminal cards, diffs, read/search icons) instead of text annotations. Conversation ids come from the stream itself, with a conversations-directory snapshot diff as fallback. **No reverse-engineered database scraping, no protobuf decoding, no token-file access** — only the official unmodified agy binary is spawned.
226
227
 
227
228
  ## 📋 What it cannot do (honest list)
228
229
 
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
2
2
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
4
+ import { CallId, LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
5
+ import { randomBytes, randomUUID } from "node:crypto";
5
6
  import { mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
6
- import { execFileSync, spawn } from "node:child_process";
7
+ import { spawn } from "node:child_process";
7
8
  import { defineTool } from "@deepseek-ai/dsh-tools";
8
9
  import { createServer } from "node:http";
9
- import { randomBytes } from "node:crypto";
10
10
  import { fileURLToPath } from "node:url";
11
11
  //#region src/common/types.ts
12
12
  const PROVIDER_ID = "antigravity";
@@ -178,7 +178,7 @@ function readJson(file) {
178
178
  function readOverrides(file = overridesPath()) {
179
179
  return readJson(file);
180
180
  }
181
- function asString(v) {
181
+ function asString$1(v) {
182
182
  return typeof v === "string" ? v : void 0;
183
183
  }
184
184
  function asBool(v) {
@@ -207,11 +207,11 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
207
207
  const cfg = {
208
208
  ...base,
209
209
  enabled: asBool(get("enabled")) ?? base.enabled,
210
- agyBin: asString(get("agyBin")) ?? base.agyBin,
210
+ agyBin: asString$1(get("agyBin")) ?? base.agyBin,
211
211
  extraArgs: Array.isArray(get("extraArgs")) ? get("extraArgs").filter((x) => typeof x === "string") : base.extraArgs,
212
212
  permissionMode: asMode(get("permissionMode")) ?? base.permissionMode,
213
- defaultModel: asString(get("defaultModel")) ?? base.defaultModel,
214
- defaultEffort: asString(get("defaultEffort")) ?? base.defaultEffort,
213
+ defaultModel: asString$1(get("defaultModel")) ?? base.defaultModel,
214
+ defaultEffort: asString$1(get("defaultEffort")) ?? base.defaultEffort,
215
215
  timeoutMs: asNum(get("timeoutMs")) ?? base.timeoutMs,
216
216
  maxConcurrent: asNum(get("maxConcurrent")) ?? base.maxConcurrent,
217
217
  contextWindowDefault: asNum(get("contextWindowDefault")) ?? base.contextWindowDefault,
@@ -221,15 +221,15 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
221
221
  modelsCacheTtlMs: asNum(get("modelsCacheTtlMs")) ?? base.modelsCacheTtlMs,
222
222
  allowAuxiliary: asBool(get("allowAuxiliary")) ?? base.allowAuxiliary,
223
223
  compactionMaxChars: asNum(get("compactionMaxChars")) ?? base.compactionMaxChars,
224
- workspaceRoot: asString(get("workspaceRoot")) ?? base.workspaceRoot,
224
+ workspaceRoot: asString$1(get("workspaceRoot")) ?? base.workspaceRoot,
225
225
  fallbackModels: Array.isArray(get("fallbackModels")) ? get("fallbackModels").filter((x) => !!x && typeof x === "object" && typeof x.id === "string") : base.fallbackModels,
226
226
  askTool: asBool(get("askTool")) ?? base.askTool,
227
- mediaDir: asString(get("mediaDir")) ?? base.mediaDir,
227
+ mediaDir: asString$1(get("mediaDir")) ?? base.mediaDir,
228
228
  mediaTtlMs: asNum(get("mediaTtlMs")) ?? base.mediaTtlMs,
229
229
  mediaMaxBytes: asNum(get("mediaMaxBytes")) ?? base.mediaMaxBytes,
230
230
  mediaMaxImages: asNum(get("mediaMaxImages")) ?? base.mediaMaxImages,
231
231
  mcpBridge: asBool(get("mcpBridge")) ?? base.mcpBridge,
232
- mcpToolAllowlist: asString(get("mcpToolAllowlist")) ?? base.mcpToolAllowlist
232
+ mcpToolAllowlist: asString$1(get("mcpToolAllowlist")) ?? base.mcpToolAllowlist
233
233
  };
234
234
  if (env.DSH_AGY_ENABLED !== void 0) cfg.enabled = asBool(env.DSH_AGY_ENABLED) ?? cfg.enabled;
235
235
  if (env.DSH_AGY_BIN) cfg.agyBin = env.DSH_AGY_BIN;
@@ -300,31 +300,158 @@ function diffConversations(before, dir = defaultConversationsDir()) {
300
300
  };
301
301
  }
302
302
  //#endregion
303
- //#region src/host/mapper.ts
304
- function briefArgs(args) {
305
- if (args === void 0 || args === null) return "";
306
- try {
307
- const s = typeof args === "string" ? args : JSON.stringify(args);
308
- return s.length > 300 ? s.slice(0, 300) + "..." : s;
309
- } catch {
310
- return String(args).slice(0, 120);
303
+ //#region src/host/recording.ts
304
+ const waiters = Symbol("waiters");
305
+ /** One agy run's append-only event log plus its settlement state. */
306
+ var RunRecording = class {
307
+ runId;
308
+ events = [];
309
+ settled = false;
310
+ failure = null;
311
+ resultConversationId = null;
312
+ [waiters] = /* @__PURE__ */ new Set();
313
+ constructor(runId = randomUUID()) {
314
+ this.runId = runId;
315
+ }
316
+ /** Append one pump event and wake every span consumer. */
317
+ append(ev) {
318
+ if (this.settled) return;
319
+ this.events.push(ev);
320
+ if (ev.kind === "result") this.resultConversationId = ev.conversationId ?? null;
321
+ this.wake();
322
+ }
323
+ /** Settle the recording: no further events will arrive. */
324
+ settle(failure) {
325
+ if (this.settled) return;
326
+ this.settled = true;
327
+ this.failure = failure;
328
+ this.wake();
329
+ }
330
+ wake() {
331
+ for (const w of this[waiters]) w();
332
+ this[waiters].clear();
333
+ }
334
+ get isSettled() {
335
+ return this.settled;
336
+ }
337
+ /** Terminal failure, when the run ended without a consumable result. */
338
+ get failureInfo() {
339
+ return this.failure;
340
+ }
341
+ /** Conversation id from the result envelope, once seen. */
342
+ get conversationId() {
343
+ return this.resultConversationId;
344
+ }
345
+ /** Whether a result event (ok or error-with-response) was recorded. */
346
+ get hasResult() {
347
+ return this.resultConversationId !== null || this.events.some((e) => e.kind === "result");
348
+ }
349
+ /** The last result envelope's finish-relevant projection, when one arrived. */
350
+ getResultEvent() {
351
+ for (let i = this.events.length - 1; i >= 0; i--) {
352
+ const ev = this.events[i];
353
+ if (ev !== void 0 && ev.kind === "result") return {
354
+ ok: ev.ok,
355
+ response: ev.response
356
+ };
357
+ }
358
+ return null;
311
359
  }
312
- }
313
- function defaultAnnounce(name, args) {
314
- const brief = briefArgs(args);
315
- return brief === "" ? "[agy tool: " + name + "]\n" : "[agy tool: " + name + "] " + brief + "\n";
316
- }
317
- function defaultOutput(output) {
318
- if (output === void 0 || output === null) return null;
319
- let s;
320
- try {
321
- s = typeof output === "string" ? output : JSON.stringify(output);
322
- } catch {
323
- s = String(output);
360
+ /** Event at an absolute index (bounds-checked). */
361
+ eventAt(index) {
362
+ return this.events[index];
363
+ }
364
+ /** Number of recorded events so far. */
365
+ get length() {
366
+ return this.events.length;
324
367
  }
325
- if (s === "") return null;
326
- return "-> " + (s.length > 2048 ? s.slice(0, 2048) + "... (+" + (s.length - 2048) + " chars)" : s) + "\n";
368
+ /**
369
+ * Whether any assistant-visible text streamed before (not including) the
370
+ * given index. Carried into each span's mapper so the result-envelope
371
+ * fallback never duplicates text earlier spans already streamed.
372
+ */
373
+ sawTextBefore(index) {
374
+ for (let i = 0; i < Math.min(index, this.events.length); i++) {
375
+ const ev = this.events[i];
376
+ if (ev !== void 0 && ev.kind === "step" && ev.stepKind === "text" && ev.text !== "") return true;
377
+ }
378
+ return false;
379
+ }
380
+ /**
381
+ * Yield events at or after `from`, waiting for the pump while the run is
382
+ * live. The iterator ends once every recorded event was yielded AND the
383
+ * recording settled — spans then inspect failureInfo to finish or fail.
384
+ */
385
+ async *eventsFrom(from) {
386
+ let cursor = from;
387
+ for (;;) {
388
+ while (cursor < this.events.length) {
389
+ yield this.events[cursor];
390
+ cursor++;
391
+ }
392
+ if (this.settled) return;
393
+ await new Promise((resolve) => {
394
+ this[waiters].add(resolve);
395
+ });
396
+ }
397
+ }
398
+ /**
399
+ * Recorded tool step for a mirror-tool call: the event at `eventIndex` must
400
+ * be the completed tool step the callId was minted from.
401
+ */
402
+ toolEventAt(eventIndex) {
403
+ const ev = this.events[eventIndex];
404
+ if (ev === void 0 || ev.kind !== "step" || ev.stepKind !== "tool" || !ev.tool) return null;
405
+ return ev.tool;
406
+ }
407
+ };
408
+ /** Prefix every mirrored agy tool callId carries; continuation detection key. */
409
+ const AGY_CALL_PREFIX = "agytc-";
410
+ /** Mint the callId for one recorded tool event. */
411
+ function mirrorCallId(runId, eventIndex) {
412
+ return AGY_CALL_PREFIX + runId + "-" + String(eventIndex);
413
+ }
414
+ /** Parse a callId back into its run coordinates; null when not ours. */
415
+ function parseMirrorCallId(callId) {
416
+ if (!callId.startsWith("agytc-")) return null;
417
+ const rest = callId.slice(6);
418
+ const idx = rest.lastIndexOf("-");
419
+ if (idx <= 0) return null;
420
+ const runId = rest.slice(0, idx);
421
+ const n = Number(rest.slice(idx + 1));
422
+ if (!Number.isSafeInteger(n) || n < 0) return null;
423
+ return {
424
+ runId,
425
+ eventIndex: n
426
+ };
327
427
  }
428
+ const MAX_RETAINED_RUNS = 8;
429
+ /** Bounded registry keeping the most recent runs for continuation spans. */
430
+ var RunRegistry = class {
431
+ runs = /* @__PURE__ */ new Map();
432
+ create() {
433
+ const rec = new RunRecording();
434
+ this.remember(rec);
435
+ return rec;
436
+ }
437
+ remember(rec) {
438
+ this.runs.set(rec.runId, rec);
439
+ while (this.runs.size > MAX_RETAINED_RUNS) {
440
+ const oldest = this.runs.keys().next().value;
441
+ if (oldest === void 0) break;
442
+ this.runs.delete(oldest);
443
+ }
444
+ }
445
+ get(runId) {
446
+ return this.runs.get(runId);
447
+ }
448
+ /** Drop a settled run early (called when its final span finished stop). */
449
+ forget(runId) {
450
+ this.runs.delete(runId);
451
+ }
452
+ };
453
+ //#endregion
454
+ //#region src/host/mapper.ts
328
455
  function usageFromRaw(raw) {
329
456
  const usage = {
330
457
  inputTokens: raw.input_tokens ?? 0,
@@ -348,12 +475,13 @@ var EventMapper = class {
348
475
  openType = null;
349
476
  openAcc = "";
350
477
  emittedByKey = /* @__PURE__ */ new Map();
351
- toolPhaseSeen = /* @__PURE__ */ new Set();
478
+ announcedTools = /* @__PURE__ */ new Set();
352
479
  thinkingAnnounced = /* @__PURE__ */ new Set();
353
- sawTextStep = false;
480
+ sawTextStep;
354
481
  finished = false;
355
- constructor(opts = {}) {
482
+ constructor(opts) {
356
483
  this.opts = opts;
484
+ this.sawTextStep = opts.initialSawText === true;
357
485
  }
358
486
  /** Whether a terminal finish chunk has been emitted. */
359
487
  get isFinished() {
@@ -403,7 +531,12 @@ var EventMapper = class {
403
531
  text: delta
404
532
  };
405
533
  }
406
- *map(ev) {
534
+ /**
535
+ * Map one event. `absIndex` is the event's position in the run recording;
536
+ * it mints the mirror callId and is what continuation detection parses
537
+ * back out of the DSH message list.
538
+ */
539
+ *map(ev, absIndex) {
407
540
  if (this.finished) return;
408
541
  if (ev.kind === "init") return;
409
542
  if (ev.kind === "garbage") return;
@@ -447,29 +580,48 @@ var EventMapper = class {
447
580
  return;
448
581
  }
449
582
  if (ev.stepKind === "tool" && ev.tool) {
450
- yield* this.ensureBlock("text");
451
- const announceKey = ev.stepKey + ":a";
452
- if (!this.toolPhaseSeen.has(announceKey)) {
453
- this.toolPhaseSeen.add(announceKey);
454
- const line = (this.opts.announce ?? defaultAnnounce)(ev.tool.name, ev.tool.args);
455
- const d = this.appendDelta("🔧 " + line);
456
- if (d) yield d;
457
- }
458
- const outKey = ev.stepKey + ":o";
459
- const errKey = ev.stepKey + ":e";
460
- if (ev.tool.error !== void 0 && !this.toolPhaseSeen.has(errKey)) {
461
- this.toolPhaseSeen.add(errKey);
462
- const d = this.appendDelta("⚠ [agy tool error: " + ev.tool.name + "] " + ev.tool.error + "\n");
463
- if (d) yield d;
464
- }
465
- if (ev.tool.output !== void 0 && !this.toolPhaseSeen.has(outKey)) {
466
- this.toolPhaseSeen.add(outKey);
467
- const rendered = this.opts.toolOutput ? this.opts.toolOutput(ev.tool.name, ev.tool.args, ev.tool.output) : defaultOutput(ev.tool.output);
468
- if (rendered !== null) {
469
- const d = this.appendDelta(rendered);
470
- if (d) yield d;
583
+ if (!(ev.tool.output !== void 0 || ev.tool.error !== void 0)) return;
584
+ if (this.announcedTools.has(ev.stepKey)) return;
585
+ this.announcedTools.add(ev.stepKey);
586
+ if (!this.opts.cutOnTool) return;
587
+ const close = this.closeOpen();
588
+ if (close) yield close;
589
+ const idx = this.blockIdx;
590
+ const input = ev.tool.args === void 0 ? {} : { input: ev.tool.args };
591
+ const argumentsJson = JSON.stringify({
592
+ run: this.opts.runId,
593
+ step: absIndex,
594
+ tool: ev.tool.name,
595
+ ...input
596
+ });
597
+ yield {
598
+ type: "block-start",
599
+ index: idx,
600
+ blockType: "tool-call"
601
+ };
602
+ yield {
603
+ type: "block-end",
604
+ index: idx,
605
+ block: {
606
+ type: "tool-call",
607
+ id: CallId(mirrorCallId(this.opts.runId, absIndex)),
608
+ name: "agy_tool",
609
+ arguments: argumentsJson
471
610
  }
472
- }
611
+ };
612
+ this.blockIdx++;
613
+ yield {
614
+ type: "usage",
615
+ usage: {
616
+ inputTokens: 0,
617
+ outputTokens: 0
618
+ }
619
+ };
620
+ yield {
621
+ type: "finish",
622
+ reason: { kind: "tool-calls" }
623
+ };
624
+ this.finished = true;
473
625
  return;
474
626
  }
475
627
  return;
@@ -1551,6 +1703,32 @@ var AgyAdapter = class extends LlmAdapter {
1551
1703
  if (isAux && !cfg.allowAuxiliary) throw new LlmError("auxiliary calls are disabled for the antigravity route (allowAuxiliary: false)", Err.AUX_DISABLED);
1552
1704
  const sessionKey = options.sessionId !== void 0 ? String(options.sessionId) : "";
1553
1705
  const workspaceRoot = cfg.workspaceRoot !== "" ? cfg.workspaceRoot : this.deps.sessionCwd?.(sessionKey) || process.cwd();
1706
+ const continuation = detectContinuation(options.messages);
1707
+ if (continuation !== null) {
1708
+ const rec = this.deps.runs.get(continuation.runId);
1709
+ if (rec === void 0) {
1710
+ yield {
1711
+ type: "usage",
1712
+ usage: {
1713
+ inputTokens: 0,
1714
+ outputTokens: 0
1715
+ }
1716
+ };
1717
+ yield {
1718
+ type: "finish",
1719
+ reason: {
1720
+ kind: "error",
1721
+ failure: {
1722
+ message: "agy run " + continuation.runId + " is no longer available (server restarted?) — please resend your message",
1723
+ code: Err.AGY_ERROR
1724
+ }
1725
+ }
1726
+ };
1727
+ return;
1728
+ }
1729
+ yield* this.driveSpan(rec, continuation.eventIndex + 1, true);
1730
+ return;
1731
+ }
1554
1732
  const catalog = this.deps.catalog.get();
1555
1733
  const model = options.model;
1556
1734
  const entry = findEntry(catalog, model);
@@ -1620,11 +1798,9 @@ var AgyAdapter = class extends LlmAdapter {
1620
1798
  if (prompt.trim() === "") throw new LlmError("request carries no user text or images to forward to agy", Err.AGY_ERROR);
1621
1799
  } else if (prompt.trim() === "") throw new LlmError("request carries no user text to forward to agy", Err.AGY_ERROR);
1622
1800
  const before = snapshotConversations();
1623
- const toolOutput = this.deps.toolOutput;
1624
- const mapper = new EventMapper({ toolOutput: toolOutput ? (name, args, output) => toolOutput(name, args, output, workspaceRoot) : void 0 });
1801
+ const rec = this.deps.runs.create();
1625
1802
  const parser = new StreamJsonParser();
1626
1803
  this.deps.onParser?.(parser);
1627
- const queue = new ChunkQueue();
1628
1804
  let streamCid = null;
1629
1805
  const args = this.buildArgs({
1630
1806
  prompt,
@@ -1655,7 +1831,7 @@ var AgyAdapter = class extends LlmAdapter {
1655
1831
  for (const ev of parser.feed(line + "\n")) {
1656
1832
  if (ev.kind === "init" && ev.conversationId) streamCid = ev.conversationId;
1657
1833
  if (ev.kind === "result" && ev.conversationId !== "") streamCid = ev.conversationId;
1658
- for (const ch of mapper.map(ev)) queue.push(ch);
1834
+ rec.append(ev);
1659
1835
  }
1660
1836
  }
1661
1837
  });
@@ -1668,10 +1844,12 @@ var AgyAdapter = class extends LlmAdapter {
1668
1844
  releaseOnce();
1669
1845
  for (const ev of parser.flush()) {
1670
1846
  if (ev.kind === "result" && ev.conversationId !== "") streamCid = ev.conversationId;
1671
- for (const ch of mapper.map(ev)) queue.push(ch);
1847
+ rec.append(ev);
1672
1848
  }
1673
1849
  const diffed = diffConversations(before).conversationId;
1674
1850
  const conversationId = streamCid ?? diffed;
1851
+ const r = rec.getResultEvent();
1852
+ const consumable = r !== null && (r.ok || r.response !== "");
1675
1853
  let failure = null;
1676
1854
  if (outcome.aborted) failure = {
1677
1855
  kind: "aborted",
@@ -1688,7 +1866,7 @@ var AgyAdapter = class extends LlmAdapter {
1688
1866
  code: Err.AUTH,
1689
1867
  message: "agy is not signed in — run /agy auth (or run agy once in a terminal) to login"
1690
1868
  };
1691
- else if (!mapper.isFinished) {
1869
+ else if (!consumable) {
1692
1870
  if (outcome.code !== 0) failure = {
1693
1871
  kind: "error",
1694
1872
  code: Err.PROCESS_EXIT,
@@ -1705,7 +1883,7 @@ var AgyAdapter = class extends LlmAdapter {
1705
1883
  message: "agy produced no result event (" + parser.stats.garbage + " unparseable lines)"
1706
1884
  };
1707
1885
  }
1708
- if (failure !== null) for (const ch of mapper.emitFailure(failure.kind, failure.code, failure.message)) queue.push(ch);
1886
+ rec.settle(failure);
1709
1887
  if (!isAux && sessionKey !== "" && failure === null) {
1710
1888
  const finalId = binding !== void 0 ? binding.conversationId : conversationId;
1711
1889
  if (finalId) this.deps.store.set(sessionKey, {
@@ -1721,15 +1899,63 @@ var AgyAdapter = class extends LlmAdapter {
1721
1899
  durationMs: outcome.durationMs,
1722
1900
  model
1723
1901
  });
1724
- queue.close();
1725
1902
  })().catch((err) => {
1726
1903
  releaseOnce();
1727
- for (const ch of mapper.emitFailure("error", Err.PROCESS_EXIT, "internal error: " + brief(String(err)))) queue.push(ch);
1728
- queue.close();
1904
+ rec.settle({
1905
+ kind: "error",
1906
+ code: Err.PROCESS_EXIT,
1907
+ message: "internal error: " + brief(String(err))
1908
+ });
1729
1909
  });
1910
+ yield* this.driveSpan(rec, 0, !isAux);
1911
+ }
1912
+ /**
1913
+ * Stream one span of a recording: map events from `from` until the mapper
1914
+ * finishes (tool-calls cut or result stop), then let the queue drain. When
1915
+ * the recording settles without a consumable result, surface its failure
1916
+ * as this span's terminal chunk — the turn ends exactly like a native
1917
+ * provider error.
1918
+ */
1919
+ async *driveSpan(rec, from, cutOnTool) {
1920
+ const queue = new ChunkQueue();
1921
+ (async () => {
1922
+ const mapper = new EventMapper({
1923
+ runId: rec.runId,
1924
+ cutOnTool,
1925
+ initialSawText: rec.sawTextBefore(from)
1926
+ });
1927
+ let i = from;
1928
+ try {
1929
+ for await (const ev of rec.eventsFrom(from)) {
1930
+ for (const ch of mapper.map(ev, i)) queue.push(ch);
1931
+ i++;
1932
+ if (mapper.isFinished) break;
1933
+ }
1934
+ if (!mapper.isFinished) {
1935
+ const f = rec.failureInfo;
1936
+ if (f !== null) for (const ch of mapper.emitFailure(f.kind, f.code, f.message)) queue.push(ch);
1937
+ else for (const ch of mapper.emitFailure("error", Err.INVALID_OUTPUT, "agy stream ended without a result event")) queue.push(ch);
1938
+ }
1939
+ } catch (err) {
1940
+ for (const ch of mapper.emitFailure("error", Err.PROCESS_EXIT, "internal error: " + brief(String(err)))) queue.push(ch);
1941
+ }
1942
+ queue.close();
1943
+ })();
1730
1944
  yield* queue.drain();
1731
1945
  }
1732
1946
  };
1947
+ /**
1948
+ * Detect a continuation span: the request's LAST message is the tool result
1949
+ * of one of our mirrored agy tool calls. Its callId encodes the recording
1950
+ * run and the event index to resume after.
1951
+ */
1952
+ function detectContinuation(messages) {
1953
+ const last = messages[messages.length - 1];
1954
+ if (last === void 0 || last.role !== "user") return null;
1955
+ const src = last.source;
1956
+ if (src === void 0 || src.kind !== "tool" || typeof src.callId !== "string") return null;
1957
+ return parseMirrorCallId(src.callId);
1958
+ }
1733
1959
  //#endregion
1734
1960
  //#region src/host/oneshot.ts
1735
1961
  /** Per-file inline cap; larger files are truncated. */
@@ -2395,73 +2621,220 @@ function writeDoctorReport(deps) {
2395
2621
  return file;
2396
2622
  }
2397
2623
  //#endregion
2398
- //#region src/host/diff-render.ts
2399
- const FILE_KEYS = [
2400
- "file_path",
2401
- "filePath",
2402
- "path",
2403
- "file",
2404
- "filename",
2405
- "target_file",
2406
- "absolute_path"
2407
- ];
2408
- function pickFile(args) {
2409
- if (!args || typeof args !== "object") return null;
2410
- const o = args;
2411
- for (const k of FILE_KEYS) {
2412
- const v = o[k];
2413
- if (typeof v === "string" && v !== "") return v;
2624
+ //#region src/host/mirror-tool.ts
2625
+ const MIRROR_TOOL_NAME = "agy_tool";
2626
+ /** agy serializes some tool args as a JSON string; presenters get an object. */
2627
+ function toolInput(args) {
2628
+ const raw = args.input;
2629
+ if (raw === void 0 || raw === null) return {};
2630
+ if (typeof raw === "object") return raw;
2631
+ if (typeof raw === "string") try {
2632
+ const parsed = JSON.parse(raw);
2633
+ return typeof parsed === "object" && parsed !== null ? parsed : { value: parsed };
2634
+ } catch {
2635
+ return { value: raw };
2414
2636
  }
2415
- return null;
2637
+ return { value: raw };
2416
2638
  }
2417
- function looksLikeEditTool(name) {
2418
- const n = name.toLowerCase();
2419
- return n.includes("write") || n.includes("edit") || n.includes("replace") || n.includes("str_replace");
2639
+ function asString(v) {
2640
+ return typeof v === "string" ? v : void 0;
2420
2641
  }
2421
- function renderToolActivity(name, args, output, cwd) {
2422
- const parts = [];
2423
- if (looksLikeEditTool(name)) {
2424
- const file = pickFile(args);
2425
- if (file) {
2426
- const diff = gitDiff(file, cwd);
2427
- if (diff !== null) parts.push("[agy edit: " + file + "]\n" + diff);
2642
+ /** Native pending-card projection for one mirrored agy tool call. */
2643
+ function presentMirrorCall(args) {
2644
+ const a = args;
2645
+ const name = typeof a?.tool === "string" ? a.tool : "";
2646
+ const input = toolInput(a);
2647
+ switch (name) {
2648
+ case "run_command":
2649
+ case "bash":
2650
+ case "execute_command": return {
2651
+ card: "terminal",
2652
+ title: asString(input.command) ?? asString(input.cmd) ?? JSON.stringify(input),
2653
+ ...asString(input.description) !== void 0 ? { description: asString(input.description) } : {},
2654
+ ...asString(input.cwd) !== void 0 ? { cwd: asString(input.cwd) } : {}
2655
+ };
2656
+ case "write_to_file":
2657
+ case "write_file":
2658
+ case "create_file": {
2659
+ const path = asString(input.path) ?? asString(input.file_path) ?? asString(input.filename) ?? "file";
2660
+ const content = asString(input.content) ?? "";
2661
+ return {
2662
+ card: "diff",
2663
+ title: "Write " + path,
2664
+ diffs: [{
2665
+ path,
2666
+ oldText: null,
2667
+ newText: content
2668
+ }],
2669
+ locations: [{ path }]
2670
+ };
2671
+ }
2672
+ case "edit_file":
2673
+ case "replace_in_file":
2674
+ case "edit": {
2675
+ const path = asString(input.path) ?? asString(input.file_path) ?? "file";
2676
+ const newText = asString(input.new_string) ?? asString(input.newText) ?? asString(input.content) ?? "";
2677
+ const oldText = asString(input.old_string) ?? asString(input.oldText) ?? null;
2678
+ return {
2679
+ card: "diff",
2680
+ title: "Edit " + path,
2681
+ diffs: [{
2682
+ path,
2683
+ oldText,
2684
+ newText
2685
+ }],
2686
+ locations: [{ path }]
2687
+ };
2688
+ }
2689
+ case "read_file":
2690
+ case "view_file":
2691
+ case "read":
2692
+ case "open_file": {
2693
+ const path = asString(input.path) ?? asString(input.file_path) ?? asString(input.filename) ?? "";
2694
+ const offset = typeof input.offset === "number" ? input.offset : void 0;
2695
+ return {
2696
+ card: "generic",
2697
+ title: "Read " + path,
2698
+ kind: "read",
2699
+ ...path !== "" ? { locations: [{
2700
+ path,
2701
+ ...offset !== void 0 ? { line: offset + 1 } : {}
2702
+ }] } : {}
2703
+ };
2428
2704
  }
2705
+ case "find_by_name":
2706
+ case "glob":
2707
+ case "search_files":
2708
+ case "search":
2709
+ case "search_file_content":
2710
+ case "grep": {
2711
+ const q = asString(input.pattern) ?? asString(input.query) ?? asString(input.regex) ?? "";
2712
+ return {
2713
+ card: "generic",
2714
+ title: q !== "" ? "Search " + q : "Search",
2715
+ kind: "search"
2716
+ };
2717
+ }
2718
+ case "list_dir":
2719
+ case "ls": {
2720
+ const path = asString(input.path) ?? asString(input.directory) ?? "";
2721
+ return {
2722
+ card: "generic",
2723
+ title: path !== "" ? "List " + path : "List directory"
2724
+ };
2725
+ }
2726
+ case "delete_file":
2727
+ case "remove_file":
2728
+ case "rm": return {
2729
+ card: "generic",
2730
+ title: "Delete " + (asString(input.path) ?? asString(input.file_path) ?? ""),
2731
+ kind: "delete"
2732
+ };
2733
+ default: return {
2734
+ card: "generic",
2735
+ title: name !== "" ? name : "agy tool",
2736
+ rawInput: input
2737
+ };
2429
2738
  }
2430
- if (output !== void 0 && output !== null) {
2431
- let s;
2432
- try {
2433
- s = typeof output === "string" ? output : JSON.stringify(output);
2434
- } catch {
2435
- s = String(output);
2739
+ }
2740
+ /** Native completed-card projection for one mirrored agy tool call. */
2741
+ function presentMirrorResult(args, result) {
2742
+ const a = args;
2743
+ const name = typeof a?.tool === "string" ? a.tool : "";
2744
+ const text = resultText$1(result.content);
2745
+ switch (name) {
2746
+ case "run_command":
2747
+ case "bash":
2748
+ case "execute_command": return {
2749
+ card: "terminal",
2750
+ output: text
2751
+ };
2752
+ case "write_to_file":
2753
+ case "write_file":
2754
+ case "create_file":
2755
+ case "edit_file":
2756
+ case "replace_in_file":
2757
+ case "edit": {
2758
+ const call = presentMirrorCall(args);
2759
+ return {
2760
+ card: "diff",
2761
+ diffs: call !== void 0 && call.card === "diff" ? call.diffs : []
2762
+ };
2436
2763
  }
2437
- if (s !== "") parts.push("-> " + (s.length > 2048 ? s.slice(0, 2048) + "..." : s));
2764
+ default: return {
2765
+ card: "generic",
2766
+ content: [{
2767
+ type: "text",
2768
+ text: clip(text, 4e3)
2769
+ }]
2770
+ };
2438
2771
  }
2439
- return parts.length === 0 ? null : parts.join("\n") + "\n";
2440
2772
  }
2441
- function gitDiff(file, cwd) {
2442
- try {
2443
- const out = execFileSync("git", [
2444
- "-C",
2445
- cwd,
2446
- "diff",
2447
- "HEAD",
2448
- "--",
2449
- file
2450
- ], {
2451
- encoding: "utf8",
2452
- timeout: 5e3,
2453
- maxBuffer: 1e6,
2454
- stdio: [
2455
- "ignore",
2456
- "pipe",
2457
- "ignore"
2458
- ]
2459
- });
2460
- if (out.trim() === "") return null;
2461
- return out.split("\n").slice(0, 100).join("\n");
2462
- } catch {
2463
- return null;
2773
+ function resultText$1(content) {
2774
+ const parts = [];
2775
+ for (const b of Array.isArray(content) ? content : []) {
2776
+ const blk = b;
2777
+ if (blk && blk.type === "text" && typeof blk.text === "string") parts.push(blk.text);
2464
2778
  }
2779
+ return parts.join("\n");
2780
+ }
2781
+ function clip(s, max) {
2782
+ return s.length > max ? s.slice(0, max) + "… (+" + (s.length - max) + " chars)" : s;
2783
+ }
2784
+ function defineAgyMirrorTool(deps) {
2785
+ return defineTool({
2786
+ name: MIRROR_TOOL_NAME,
2787
+ description: "Internal to the dsh-agy-link bridge: replays one tool activity recorded from a Google Antigravity (agy) CLI run so it renders as a native tool card and rides the agent loop. Emitted automatically by the antigravity provider — do not call it directly.",
2788
+ parameters: {
2789
+ run: {
2790
+ type: "string",
2791
+ required: true,
2792
+ description: "Recording run id."
2793
+ },
2794
+ step: {
2795
+ type: "number",
2796
+ required: true,
2797
+ description: "Recorded event index of the tool step."
2798
+ },
2799
+ tool: {
2800
+ type: "string",
2801
+ required: true,
2802
+ description: "agy tool name that ran."
2803
+ },
2804
+ input: {
2805
+ type: "json",
2806
+ description: "agy tool arguments as recorded."
2807
+ }
2808
+ },
2809
+ output: {
2810
+ schema: { type: "string" },
2811
+ render: (_args, value) => [{
2812
+ type: "text",
2813
+ text: value
2814
+ }]
2815
+ },
2816
+ presentCall: (args) => presentMirrorCall(args),
2817
+ presentResult: (args, result) => presentMirrorResult(args, result),
2818
+ async execute(args, exec) {
2819
+ exec.signal;
2820
+ const runId = typeof args.run === "string" ? args.run : "";
2821
+ const step = typeof args.step === "number" ? args.step : -1;
2822
+ typeof args.tool === "string" && args.tool;
2823
+ const rec = deps.runs.get(runId);
2824
+ if (rec === void 0) throw new Error("agy_tool: no recorded agy run \"" + runId + "\" — this tool only replays bridge-recorded activity");
2825
+ const t = rec.toolEventAt(step);
2826
+ if (t === null) throw new Error("agy_tool: event " + step + " of run " + runId + " is not a completed tool step");
2827
+ if (t.error !== void 0) throw new Error("agy tool " + t.name + " failed: " + t.error);
2828
+ const out = t.output;
2829
+ if (out === void 0 || out === null) return "";
2830
+ if (typeof out === "string") return out;
2831
+ try {
2832
+ return JSON.stringify(out, null, 2);
2833
+ } catch {
2834
+ return String(out);
2835
+ }
2836
+ }
2837
+ });
2465
2838
  }
2466
2839
  //#endregion
2467
2840
  //#region src/host/sessions.ts
@@ -2753,6 +3126,7 @@ function apply(ctx, entryConfig = {}) {
2753
3126
  };
2754
3127
  }, getConfig().fallbackModels, 3e5);
2755
3128
  const auth = new AuthHelper(bin);
3129
+ const runs = new RunRegistry();
2756
3130
  const adapter = new AgyAdapter({
2757
3131
  getConfig,
2758
3132
  catalog,
@@ -2760,10 +3134,7 @@ function apply(ctx, entryConfig = {}) {
2760
3134
  bin,
2761
3135
  acquire: () => semaphore.acquire(),
2762
3136
  log,
2763
- toolOutput: (name, args, output, cwd) => {
2764
- const ws = getConfig().workspaceRoot;
2765
- return renderToolActivity(name, args, output, cwd ?? (ws !== "" ? ws : process.cwd()));
2766
- },
3137
+ runs,
2767
3138
  sessionCwd: (sessionId) => {
2768
3139
  return ctx.get("sessions")?.get(sessionId)?.header?.cwd;
2769
3140
  },
@@ -2861,6 +3232,18 @@ function apply(ctx, entryConfig = {}) {
2861
3232
  }
2862
3233
  };
2863
3234
  syncAskTool();
3235
+ const mirrorToolDispose = { current: null };
3236
+ const syncMirrorTool = () => {
3237
+ const want = getConfig().enabled && bin() !== null;
3238
+ if (want && mirrorToolDispose.current === null && toolsSvc) {
3239
+ const reg = toolsSvc.register(defineAgyMirrorTool({ runs }));
3240
+ mirrorToolDispose.current = typeof reg === "function" ? reg : null;
3241
+ } else if (!want && mirrorToolDispose.current !== null) {
3242
+ mirrorToolDispose.current();
3243
+ mirrorToolDispose.current = null;
3244
+ }
3245
+ };
3246
+ syncMirrorTool();
2864
3247
  const registerRoutes = (webServer) => {
2865
3248
  const disposers = [];
2866
3249
  const reg = (route) => {
@@ -2979,6 +3362,7 @@ function apply(ctx, entryConfig = {}) {
2979
3362
  }
2980
3363
  setOverride(key, body.value);
2981
3364
  syncAskTool();
3365
+ syncMirrorTool();
2982
3366
  sendJson(res, 200, {
2983
3367
  ok: true,
2984
3368
  key,
@@ -3075,6 +3459,7 @@ function apply(ctx, entryConfig = {}) {
3075
3459
  ctx.effect(() => {
3076
3460
  auth.dispose();
3077
3461
  if (askToolDispose.current !== null) askToolDispose.current();
3462
+ if (mirrorToolDispose.current !== null) mirrorToolDispose.current();
3078
3463
  bridgeState.restore?.();
3079
3464
  bridgeState.bridge?.close();
3080
3465
  return () => void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agy-link",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "Google Antigravity (agy CLI) models for DeepSeek Harness — stream Gemini/Claude/GPT-OSS subscriptions into DSH with thinking, tool activity, token usage and in-GUI Google OAuth login.",
5
5
  "type": "module",
6
6
  "license": "MIT",