dsh-agy-link 0.2.8 โ†’ 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.9 (2026-08-19)
4
+
5
+ - **Tool activity moved out of the thinking panel into the reply body.**
6
+ User feedback on 0.2.8: tool annotations hidden inside the DSH thinking
7
+ fold were invisible and felt wrong. agy tools now render as visible
8
+ `๐Ÿ”ง [agy tool: name] args -> output` lines in the message body (they
9
+ cannot become native DSH tool cards: a finish:tool-calls would make the
10
+ DSH agent try to execute tools it does not own โ€” agy runs its own closed
11
+ tool loop). The thinking panel keeps only what is genuinely thinking
12
+ signal: `[agy thinking turn ยท N thinking tokens]` turn annotations
13
+ (agy print mode never streams thinking text) and terminal error notes.
14
+
3
15
  ## 0.2.8 (2026-08-19)
4
16
 
5
17
  - **Fix: real agy 1.1.15 stream-json parsing.** The parser only understood
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 activity โ†’ annotated 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 envelope โ†’ usage + 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;
@@ -433,42 +566,64 @@ var EventMapper = class {
433
566
  if (d) yield d;
434
567
  return;
435
568
  }
436
- if (ev.stepKind === "thinking" || ev.stepKind === "tool" || ev.stepKind === "subagent") {
569
+ if (ev.stepKind === "thinking" || ev.stepKind === "subagent") {
437
570
  yield* this.ensureBlock("reasoning");
438
571
  if (ev.stepKind === "thinking") {
439
572
  const delta = suffixDelta(this.emittedByKey.get(ev.stepKey) ?? "", ev.text);
440
573
  this.emittedByKey.set(ev.stepKey, ev.text);
441
574
  const d = this.appendDelta(delta);
442
575
  if (d) yield d;
443
- } else if (ev.stepKind === "tool" && ev.tool) {
444
- const announceKey = ev.stepKey + ":a";
445
- if (!this.toolPhaseSeen.has(announceKey)) {
446
- this.toolPhaseSeen.add(announceKey);
447
- const line = (this.opts.announce ?? defaultAnnounce)(ev.tool.name, ev.tool.args);
448
- const d = this.appendDelta(line);
449
- if (d) yield d;
450
- }
451
- const outKey = ev.stepKey + ":o";
452
- const errKey = ev.stepKey + ":e";
453
- if (ev.tool.error !== void 0 && !this.toolPhaseSeen.has(errKey)) {
454
- this.toolPhaseSeen.add(errKey);
455
- const d = this.appendDelta("[agy tool error: " + ev.tool.name + "] " + ev.tool.error + "\n");
456
- if (d) yield d;
457
- }
458
- if (ev.tool.output !== void 0 && !this.toolPhaseSeen.has(outKey)) {
459
- this.toolPhaseSeen.add(outKey);
460
- const rendered = this.opts.toolOutput ? this.opts.toolOutput(ev.tool.name, ev.tool.args, ev.tool.output) : defaultOutput(ev.tool.output);
461
- if (rendered !== null) {
462
- const d = this.appendDelta(rendered);
463
- if (d) yield d;
464
- }
465
- }
466
- } else if (ev.stepKind === "subagent") {
576
+ } else {
467
577
  const d = this.appendDelta("[agy subagent] " + ev.text + "\n");
468
578
  if (d) yield d;
469
579
  }
470
580
  return;
471
581
  }
582
+ if (ev.stepKind === "tool" && ev.tool) {
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
610
+ }
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;
625
+ return;
626
+ }
472
627
  return;
473
628
  }
474
629
  if (!ev.ok) {
@@ -1548,6 +1703,32 @@ var AgyAdapter = class extends LlmAdapter {
1548
1703
  if (isAux && !cfg.allowAuxiliary) throw new LlmError("auxiliary calls are disabled for the antigravity route (allowAuxiliary: false)", Err.AUX_DISABLED);
1549
1704
  const sessionKey = options.sessionId !== void 0 ? String(options.sessionId) : "";
1550
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
+ }
1551
1732
  const catalog = this.deps.catalog.get();
1552
1733
  const model = options.model;
1553
1734
  const entry = findEntry(catalog, model);
@@ -1617,11 +1798,9 @@ var AgyAdapter = class extends LlmAdapter {
1617
1798
  if (prompt.trim() === "") throw new LlmError("request carries no user text or images to forward to agy", Err.AGY_ERROR);
1618
1799
  } else if (prompt.trim() === "") throw new LlmError("request carries no user text to forward to agy", Err.AGY_ERROR);
1619
1800
  const before = snapshotConversations();
1620
- const toolOutput = this.deps.toolOutput;
1621
- const mapper = new EventMapper({ toolOutput: toolOutput ? (name, args, output) => toolOutput(name, args, output, workspaceRoot) : void 0 });
1801
+ const rec = this.deps.runs.create();
1622
1802
  const parser = new StreamJsonParser();
1623
1803
  this.deps.onParser?.(parser);
1624
- const queue = new ChunkQueue();
1625
1804
  let streamCid = null;
1626
1805
  const args = this.buildArgs({
1627
1806
  prompt,
@@ -1652,7 +1831,7 @@ var AgyAdapter = class extends LlmAdapter {
1652
1831
  for (const ev of parser.feed(line + "\n")) {
1653
1832
  if (ev.kind === "init" && ev.conversationId) streamCid = ev.conversationId;
1654
1833
  if (ev.kind === "result" && ev.conversationId !== "") streamCid = ev.conversationId;
1655
- for (const ch of mapper.map(ev)) queue.push(ch);
1834
+ rec.append(ev);
1656
1835
  }
1657
1836
  }
1658
1837
  });
@@ -1665,10 +1844,12 @@ var AgyAdapter = class extends LlmAdapter {
1665
1844
  releaseOnce();
1666
1845
  for (const ev of parser.flush()) {
1667
1846
  if (ev.kind === "result" && ev.conversationId !== "") streamCid = ev.conversationId;
1668
- for (const ch of mapper.map(ev)) queue.push(ch);
1847
+ rec.append(ev);
1669
1848
  }
1670
1849
  const diffed = diffConversations(before).conversationId;
1671
1850
  const conversationId = streamCid ?? diffed;
1851
+ const r = rec.getResultEvent();
1852
+ const consumable = r !== null && (r.ok || r.response !== "");
1672
1853
  let failure = null;
1673
1854
  if (outcome.aborted) failure = {
1674
1855
  kind: "aborted",
@@ -1685,7 +1866,7 @@ var AgyAdapter = class extends LlmAdapter {
1685
1866
  code: Err.AUTH,
1686
1867
  message: "agy is not signed in โ€” run /agy auth (or run agy once in a terminal) to login"
1687
1868
  };
1688
- else if (!mapper.isFinished) {
1869
+ else if (!consumable) {
1689
1870
  if (outcome.code !== 0) failure = {
1690
1871
  kind: "error",
1691
1872
  code: Err.PROCESS_EXIT,
@@ -1702,7 +1883,7 @@ var AgyAdapter = class extends LlmAdapter {
1702
1883
  message: "agy produced no result event (" + parser.stats.garbage + " unparseable lines)"
1703
1884
  };
1704
1885
  }
1705
- if (failure !== null) for (const ch of mapper.emitFailure(failure.kind, failure.code, failure.message)) queue.push(ch);
1886
+ rec.settle(failure);
1706
1887
  if (!isAux && sessionKey !== "" && failure === null) {
1707
1888
  const finalId = binding !== void 0 ? binding.conversationId : conversationId;
1708
1889
  if (finalId) this.deps.store.set(sessionKey, {
@@ -1718,15 +1899,63 @@ var AgyAdapter = class extends LlmAdapter {
1718
1899
  durationMs: outcome.durationMs,
1719
1900
  model
1720
1901
  });
1721
- queue.close();
1722
1902
  })().catch((err) => {
1723
1903
  releaseOnce();
1724
- for (const ch of mapper.emitFailure("error", Err.PROCESS_EXIT, "internal error: " + brief(String(err)))) queue.push(ch);
1725
- queue.close();
1904
+ rec.settle({
1905
+ kind: "error",
1906
+ code: Err.PROCESS_EXIT,
1907
+ message: "internal error: " + brief(String(err))
1908
+ });
1726
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
+ })();
1727
1944
  yield* queue.drain();
1728
1945
  }
1729
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
+ }
1730
1959
  //#endregion
1731
1960
  //#region src/host/oneshot.ts
1732
1961
  /** Per-file inline cap; larger files are truncated. */
@@ -2392,73 +2621,203 @@ function writeDoctorReport(deps) {
2392
2621
  return file;
2393
2622
  }
2394
2623
  //#endregion
2395
- //#region src/host/diff-render.ts
2396
- const FILE_KEYS = [
2397
- "file_path",
2398
- "filePath",
2399
- "path",
2400
- "file",
2401
- "filename",
2402
- "target_file",
2403
- "absolute_path"
2404
- ];
2405
- function pickFile(args) {
2406
- if (!args || typeof args !== "object") return null;
2407
- const o = args;
2408
- for (const k of FILE_KEYS) {
2409
- const v = o[k];
2410
- 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 };
2411
2636
  }
2412
- return null;
2637
+ return { value: raw };
2413
2638
  }
2414
- function looksLikeEditTool(name) {
2415
- const n = name.toLowerCase();
2416
- 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;
2417
2641
  }
2418
- function renderToolActivity(name, args, output, cwd) {
2419
- const parts = [];
2420
- if (looksLikeEditTool(name)) {
2421
- const file = pickFile(args);
2422
- if (file) {
2423
- const diff = gitDiff(file, cwd);
2424
- 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
+ };
2425
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 "read":
2691
+ case "open_file": {
2692
+ const path = asString(input.path) ?? asString(input.file_path) ?? asString(input.filename) ?? "";
2693
+ const offset = typeof input.offset === "number" ? input.offset : void 0;
2694
+ return {
2695
+ card: "generic",
2696
+ title: "Read " + path,
2697
+ kind: "read",
2698
+ ...path !== "" ? { locations: [{
2699
+ path,
2700
+ ...offset !== void 0 ? { line: offset + 1 } : {}
2701
+ }] } : {}
2702
+ };
2703
+ }
2704
+ case "find_by_name":
2705
+ case "glob":
2706
+ case "search_files":
2707
+ case "search":
2708
+ case "grep": {
2709
+ const q = asString(input.pattern) ?? asString(input.query) ?? asString(input.regex) ?? "";
2710
+ return {
2711
+ card: "generic",
2712
+ title: q !== "" ? "Search " + q : "Search",
2713
+ kind: "search"
2714
+ };
2715
+ }
2716
+ default: return {
2717
+ card: "generic",
2718
+ title: name !== "" ? name : "agy tool",
2719
+ rawInput: input
2720
+ };
2426
2721
  }
2427
- if (output !== void 0 && output !== null) {
2428
- let s;
2429
- try {
2430
- s = typeof output === "string" ? output : JSON.stringify(output);
2431
- } catch {
2432
- s = String(output);
2722
+ }
2723
+ /** Native completed-card projection for one mirrored agy tool call. */
2724
+ function presentMirrorResult(args, result) {
2725
+ const a = args;
2726
+ const name = typeof a?.tool === "string" ? a.tool : "";
2727
+ const text = resultText$1(result.content);
2728
+ switch (name) {
2729
+ case "run_command":
2730
+ case "bash":
2731
+ case "execute_command": return {
2732
+ card: "terminal",
2733
+ output: text
2734
+ };
2735
+ case "write_to_file":
2736
+ case "write_file":
2737
+ case "create_file":
2738
+ case "edit_file":
2739
+ case "replace_in_file":
2740
+ case "edit": {
2741
+ const call = presentMirrorCall(args);
2742
+ return {
2743
+ card: "diff",
2744
+ diffs: call !== void 0 && call.card === "diff" ? call.diffs : []
2745
+ };
2433
2746
  }
2434
- if (s !== "") parts.push("-> " + (s.length > 2048 ? s.slice(0, 2048) + "..." : s));
2747
+ default: return {
2748
+ card: "generic",
2749
+ content: [{
2750
+ type: "text",
2751
+ text: clip(text, 4e3)
2752
+ }]
2753
+ };
2435
2754
  }
2436
- return parts.length === 0 ? null : parts.join("\n") + "\n";
2437
2755
  }
2438
- function gitDiff(file, cwd) {
2439
- try {
2440
- const out = execFileSync("git", [
2441
- "-C",
2442
- cwd,
2443
- "diff",
2444
- "HEAD",
2445
- "--",
2446
- file
2447
- ], {
2448
- encoding: "utf8",
2449
- timeout: 5e3,
2450
- maxBuffer: 1e6,
2451
- stdio: [
2452
- "ignore",
2453
- "pipe",
2454
- "ignore"
2455
- ]
2456
- });
2457
- if (out.trim() === "") return null;
2458
- return out.split("\n").slice(0, 100).join("\n");
2459
- } catch {
2460
- return null;
2756
+ function resultText$1(content) {
2757
+ const parts = [];
2758
+ for (const b of Array.isArray(content) ? content : []) {
2759
+ const blk = b;
2760
+ if (blk && blk.type === "text" && typeof blk.text === "string") parts.push(blk.text);
2461
2761
  }
2762
+ return parts.join("\n");
2763
+ }
2764
+ function clip(s, max) {
2765
+ return s.length > max ? s.slice(0, max) + "โ€ฆ (+" + (s.length - max) + " chars)" : s;
2766
+ }
2767
+ function defineAgyMirrorTool(deps) {
2768
+ return defineTool({
2769
+ name: MIRROR_TOOL_NAME,
2770
+ 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.",
2771
+ parameters: {
2772
+ run: {
2773
+ type: "string",
2774
+ required: true,
2775
+ description: "Recording run id."
2776
+ },
2777
+ step: {
2778
+ type: "number",
2779
+ required: true,
2780
+ description: "Recorded event index of the tool step."
2781
+ },
2782
+ tool: {
2783
+ type: "string",
2784
+ required: true,
2785
+ description: "agy tool name that ran."
2786
+ },
2787
+ input: {
2788
+ type: "json",
2789
+ description: "agy tool arguments as recorded."
2790
+ }
2791
+ },
2792
+ output: {
2793
+ schema: { type: "string" },
2794
+ render: (_args, value) => [{
2795
+ type: "text",
2796
+ text: value
2797
+ }]
2798
+ },
2799
+ presentCall: (args) => presentMirrorCall(args),
2800
+ presentResult: (args, result) => presentMirrorResult(args, result),
2801
+ async execute(args, exec) {
2802
+ exec.signal;
2803
+ const runId = typeof args.run === "string" ? args.run : "";
2804
+ const step = typeof args.step === "number" ? args.step : -1;
2805
+ typeof args.tool === "string" && args.tool;
2806
+ const rec = deps.runs.get(runId);
2807
+ if (rec === void 0) throw new Error("agy_tool: no recorded agy run \"" + runId + "\" โ€” this tool only replays bridge-recorded activity");
2808
+ const t = rec.toolEventAt(step);
2809
+ if (t === null) throw new Error("agy_tool: event " + step + " of run " + runId + " is not a completed tool step");
2810
+ if (t.error !== void 0) throw new Error("agy tool " + t.name + " failed: " + t.error);
2811
+ const out = t.output;
2812
+ if (out === void 0 || out === null) return "";
2813
+ if (typeof out === "string") return out;
2814
+ try {
2815
+ return JSON.stringify(out, null, 2);
2816
+ } catch {
2817
+ return String(out);
2818
+ }
2819
+ }
2820
+ });
2462
2821
  }
2463
2822
  //#endregion
2464
2823
  //#region src/host/sessions.ts
@@ -2750,6 +3109,7 @@ function apply(ctx, entryConfig = {}) {
2750
3109
  };
2751
3110
  }, getConfig().fallbackModels, 3e5);
2752
3111
  const auth = new AuthHelper(bin);
3112
+ const runs = new RunRegistry();
2753
3113
  const adapter = new AgyAdapter({
2754
3114
  getConfig,
2755
3115
  catalog,
@@ -2757,10 +3117,7 @@ function apply(ctx, entryConfig = {}) {
2757
3117
  bin,
2758
3118
  acquire: () => semaphore.acquire(),
2759
3119
  log,
2760
- toolOutput: (name, args, output, cwd) => {
2761
- const ws = getConfig().workspaceRoot;
2762
- return renderToolActivity(name, args, output, cwd ?? (ws !== "" ? ws : process.cwd()));
2763
- },
3120
+ runs,
2764
3121
  sessionCwd: (sessionId) => {
2765
3122
  return ctx.get("sessions")?.get(sessionId)?.header?.cwd;
2766
3123
  },
@@ -2858,6 +3215,18 @@ function apply(ctx, entryConfig = {}) {
2858
3215
  }
2859
3216
  };
2860
3217
  syncAskTool();
3218
+ const mirrorToolDispose = { current: null };
3219
+ const syncMirrorTool = () => {
3220
+ const want = getConfig().enabled && bin() !== null;
3221
+ if (want && mirrorToolDispose.current === null && toolsSvc) {
3222
+ const reg = toolsSvc.register(defineAgyMirrorTool({ runs }));
3223
+ mirrorToolDispose.current = typeof reg === "function" ? reg : null;
3224
+ } else if (!want && mirrorToolDispose.current !== null) {
3225
+ mirrorToolDispose.current();
3226
+ mirrorToolDispose.current = null;
3227
+ }
3228
+ };
3229
+ syncMirrorTool();
2861
3230
  const registerRoutes = (webServer) => {
2862
3231
  const disposers = [];
2863
3232
  const reg = (route) => {
@@ -2976,6 +3345,7 @@ function apply(ctx, entryConfig = {}) {
2976
3345
  }
2977
3346
  setOverride(key, body.value);
2978
3347
  syncAskTool();
3348
+ syncMirrorTool();
2979
3349
  sendJson(res, 200, {
2980
3350
  ok: true,
2981
3351
  key,
@@ -3072,6 +3442,7 @@ function apply(ctx, entryConfig = {}) {
3072
3442
  ctx.effect(() => {
3073
3443
  auth.dispose();
3074
3444
  if (askToolDispose.current !== null) askToolDispose.current();
3445
+ if (mirrorToolDispose.current !== null) mirrorToolDispose.current();
3075
3446
  bridgeState.restore?.();
3076
3447
  bridgeState.bridge?.close();
3077
3448
  return () => void 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agy-link",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
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",