my-durable 0.0.29 → 0.0.31

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.
@@ -67,6 +67,12 @@ export function installFaux(models: MutableModels) {
67
67
  const ref = prompt.slice(6).trim();
68
68
  return call("check_durable_job", /^\d+$/.test(ref) ? { task_id: Number(ref) } : ref ? { job: ref } : {});
69
69
  }
70
+ if (prompt.startsWith("write:")) {
71
+ // "write:<path>|<content>" — exercises the real write tool so git-panel
72
+ // features (session changelist, per-turn summary) are testable.
73
+ const [path, ...rest] = prompt.slice(6).split("|");
74
+ return call("write", { path: (path ?? "").trim(), content: rest.join("|") });
75
+ }
70
76
  if (prompt.startsWith("search:")) return call("search_history", { query: prompt.slice(7).trim() });
71
77
  if (prompt.startsWith("page:")) return call("ping_on_call_engineer", { summary: prompt.slice(5).trim() });
72
78
  if (prompt.startsWith("Release check:"))
@@ -41,6 +41,18 @@ export function git(cwd: string, args: string[], timeout = GIT_TIMEOUT): Promise
41
41
  });
42
42
  }
43
43
 
44
+ /** One GitHub CLI call — PRs, checks and comments are forge territory and gh
45
+ * is the least-token-ceremony way to reach them from a device. Same no-shell
46
+ * rule: argv only, plain text back. */
47
+ export function gh(cwd: string, args: string[], timeout = GIT_TIMEOUT): Promise<string> {
48
+ return new Promise((ok, fail) => {
49
+ execFile("gh", ["--repo", cwd, ...args], { timeout, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
50
+ if (error) fail(Error(String(stderr || stdout || error.message).trim().slice(0, 400)));
51
+ else ok(String(stdout));
52
+ });
53
+ });
54
+ }
55
+
44
56
  const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
45
57
 
46
58
  export interface GitStatus {
@@ -230,7 +242,13 @@ export type GitOp =
230
242
  | "ignore"
231
243
  | "checkpoint"
232
244
  | "checkpoints"
233
- | "rewind";
245
+ | "rewind"
246
+ | "submodule-update"
247
+ | "pr-create"
248
+ | "pr-list"
249
+ | "pr-merge"
250
+ | "pr-checks"
251
+ | "pr-comment";
234
252
 
235
253
  /** Plain string an arg may never contain — we build argv ourselves anyway. */
236
254
  const safeRef = (s: string) => /^[\w./@+-]+$/.test(s) && !s.includes("..");
@@ -255,6 +273,10 @@ export async function gitOp(
255
273
  name?: string;
256
274
  /** ignore: pattern lines to append to .gitignore. */
257
275
  lines?: string[];
276
+ /** pr: merge method + comment body + PR number. */
277
+ method?: "squash" | "merge";
278
+ body?: string;
279
+ n?: number;
258
280
  } = {},
259
281
  ): Promise<{
260
282
  text: string;
@@ -263,8 +285,9 @@ export async function gitOp(
263
285
  behind?: number;
264
286
  changed?: { status: string; path: string }[];
265
287
  checkpoints?: { name: string; hash: string; date: string; subject: string }[];
288
+ prs?: { n: number; title: string; state: string; draft: boolean; branch: string; url: string }[];
266
289
  }> {
267
- const { ref, message, target, branch, files, create, staged, rebase, setUpstream, name, lines } = opts;
290
+ const { ref, message, target, branch, files, create, staged, rebase, setUpstream, name, lines, method, body, n } = opts;
268
291
  switch (op) {
269
292
  case "diff":
270
293
  return { text: await git(path, ["--no-pager", "diff", ...(ref && safeRef(ref) ? [ref] : [])]) };
@@ -412,6 +435,38 @@ export async function gitOp(
412
435
  const sha = (await git(path, ["rev-parse", "--verify", refname])).trim();
413
436
  return { text: (await git(path, ["reset", "--hard", sha])).trim().split("\n")[0] ?? `rewound to ${name}` };
414
437
  }
438
+ case "submodule-update":
439
+ return { text: (await git(path, ["submodule", "update", "--init", "--recursive"], GIT_SLOW_TIMEOUT)).trim() || "submodules up to date" };
440
+ case "pr-create": {
441
+ // gh pr create — title from message, head = current branch. gh owns auth.
442
+ const title = (message ?? "").trim();
443
+ if (!title) throw Error("pr-create needs a title (message)");
444
+ const args = ["pr", "create", "--title", title.slice(0, 200), "--body", (body ?? title).slice(0, 4000)];
445
+ if (ref && safeRef(ref)) args.push("--base", ref);
446
+ return { text: (await gh(path, args, GIT_SLOW_TIMEOUT)).trim() };
447
+ }
448
+ case "pr-list": {
449
+ const raw = await gh(path, ["pr", "list", "--limit", "20", "--json", "number,title,state,isDraft,headRefName,url"]);
450
+ const list = JSON.parse(raw || "[]") as { number: number; title: string; state: string; isDraft: boolean; headRefName: string; url: string }[];
451
+ return {
452
+ text: "",
453
+ prs: list.map((r) => ({ n: r.number, title: r.title, state: r.state, draft: r.isDraft, branch: r.headRefName, url: r.url })),
454
+ };
455
+ }
456
+ case "pr-merge": {
457
+ if (!n || !Number.isInteger(n)) throw Error("pr-merge needs a PR number");
458
+ return { text: (await gh(path, ["pr", "merge", String(n), method === "merge" ? "--merge" : "--squash"], GIT_SLOW_TIMEOUT)).trim() || `PR #${n} merged` };
459
+ }
460
+ case "pr-checks": {
461
+ if (!n || !Number.isInteger(n)) throw Error("pr-checks needs a PR number");
462
+ return { text: (await gh(path, ["pr", "checks", String(n)], GIT_SLOW_TIMEOUT)).trim() || "no checks reported" };
463
+ }
464
+ case "pr-comment": {
465
+ if (!n || !Number.isInteger(n)) throw Error("pr-comment needs a PR number");
466
+ const text = (body ?? "").trim();
467
+ if (!text) throw Error("pr-comment needs a body");
468
+ return { text: (await gh(path, ["pr", "comment", String(n), "--body", text.slice(0, 4000)])).trim() };
469
+ }
415
470
  case "commit": {
416
471
  const msg = (message ?? "").trim();
417
472
  if (!msg) throw Error("commit needs a message");
@@ -112,6 +112,13 @@ export function v1(
112
112
 
113
113
  // ---------------------------------------------------------- device
114
114
  r.get("/device", (_req, res) => res.json(store.deviceInfo()));
115
+ // Model health, probed live: one provider, its first catalog model, one
116
+ // throwaway session. Local control plane only — deliberately NOT tunnelled.
117
+ r.post("/device/probe-model", route(async (req, res) => {
118
+ const provider = String(req.body?.provider ?? "");
119
+ const modelId = String(req.body?.modelId ?? "");
120
+ res.json(await store.probeModel(provider, modelId));
121
+ }));
115
122
  r.get("/tree", route(async (_req, res) => res.json(await store.tree())));
116
123
  r.get("/events", (req, res, next) => {
117
124
  try {
@@ -145,6 +152,9 @@ export function v1(
145
152
  setUpstream: body.setUpstream === true,
146
153
  name: typeof body.name === "string" ? body.name : undefined,
147
154
  lines: Array.isArray(body.lines) ? body.lines.map(String).slice(0, 50) : undefined,
155
+ method: body.method === "merge" ? "merge" : "squash",
156
+ body: typeof body.body === "string" ? body.body : undefined,
157
+ n: Number.isInteger(body.n) ? Number(body.n) : undefined,
148
158
  });
149
159
  res.json(out);
150
160
  }));
@@ -169,6 +179,8 @@ export function v1(
169
179
  // The zcode flagship, adapted: which files has THIS session's agent touched?
170
180
  // Powers "commit session changes" in the git panel.
171
181
  r.get("/sessions/:s/git-files", route(async (_req, res) => res.json({ files: await store.sessionTouchedFiles(L(res).row) })));
182
+ // Per-turn breakdown of the same data (zcode taskChangeSummary shape).
183
+ r.get("/sessions/:s/git-turns", route(async (_req, res) => res.json({ turns: await store.sessionGitTurns(L(res).row) })));
172
184
  r.get(
173
185
  "/sessions/:s/tasks",
174
186
  route(async (req, res) => {
@@ -301,7 +301,15 @@ export class Store {
301
301
  }
302
302
 
303
303
  private defaultModel() {
304
- return this.options.provider === "faux" ? fauxModels[0].id : defaultModelId;
304
+ if (this.options.provider === "faux") return fauxModels[0].id;
305
+ // The curated constant can drift out of the LIVE catalog (seen: default
306
+ // anthropic/claude-sonnet-5.5 while the credential store held 41 other
307
+ // models) — never point the default outside the catalog we serve.
308
+ // (this.modelList, NOT deviceInfo() — that would recurse.)
309
+ const catalog = this.modelList;
310
+ return catalog.some((m) => `${m.provider}/${m.id}` === defaultModelId)
311
+ ? defaultModelId
312
+ : (catalog[0] ? `${catalog[0].provider}/${catalog[0].id}` : defaultModelId);
305
313
  }
306
314
 
307
315
  /** True when the device has no provider key: the catalog shown is the built-in
@@ -761,6 +769,88 @@ export class Store {
761
769
  return [...paths];
762
770
  }
763
771
 
772
+ /**
773
+ * Per-turn file summary (zcode taskChangeSummary): every user turn of the
774
+ * session, with the files its agents wrote or edited during that turn.
775
+ * Line stats come from the UI against the live status — here we only know
776
+ * WHAT was touched and how many times.
777
+ */
778
+ async sessionGitTurns(row: SessionRow): Promise<{ when: string; files: { path: string; writes: number }[] }[]> {
779
+ const h = await this.openAgent(row);
780
+ const scanned: { when: string; files: Map<string, number> }[] = [];
781
+ let current: { when: string; files: Map<string, number> } | null = null;
782
+ const fileOf = (b: { type?: string; name?: string; arguments?: Record<string, unknown> }): string[] => {
783
+ if (b?.type !== "toolCall" || (b.name !== "write" && b.name !== "edit")) return [];
784
+ const args = b.arguments ?? {};
785
+ const out: string[] = [];
786
+ for (const f of [args.file_path, args.path]) if (typeof f === "string" && f.length > 0 && f.length < 4096) out.push(f);
787
+ const edits = Array.isArray(args.edits) ? (args.edits as { path?: unknown }[]) : [];
788
+ for (const ed of edits) if (typeof ed.path === "string" && ed.path.length > 0) out.push(ed.path);
789
+ return out;
790
+ };
791
+ await h.commit(async (tx) => {
792
+ let convs = await tx.scanConversations({}, 1000);
793
+ const records = [...convs.items];
794
+ while (convs.next && records.length < 100) {
795
+ convs = await tx.scanConversations({}, 1000, convs.next);
796
+ records.push(...convs.items);
797
+ }
798
+ for (const c of records) {
799
+ // ascending: a turn's tool calls FOLLOW its user entry (default is
800
+ // descending, which attributes every call to the NEXT turn — caught
801
+ // by the git-session-files test).
802
+ let page = await tx.scanEntries({ conversationId: c.id, order: "ascending" }, 500);
803
+ let count = 0;
804
+ for (;;) {
805
+ for (const e of page.items as unknown as { kind?: string; model?: readonly { content?: unknown }[] }[]) {
806
+ if (e.kind === "pi.user") {
807
+ current = { when: new Date().toISOString(), files: new Map() };
808
+ scanned.push(current);
809
+ continue;
810
+ }
811
+ if (!current) continue;
812
+ for (const m of e.model ?? []) {
813
+ const blocks = Array.isArray(m.content) ? (m.content as unknown[]) : [];
814
+ for (const b of blocks as { type?: string; name?: string; arguments?: Record<string, unknown> }[]) {
815
+ for (const f of fileOf(b)) current.files.set(f, (current.files.get(f) ?? 0) + 1);
816
+ }
817
+ }
818
+ }
819
+ count += page.items.length;
820
+ if (!page.next || count >= 4000) break;
821
+ page = await tx.scanEntries({ conversationId: c.id }, 500, page.next);
822
+ }
823
+ }
824
+ }, ctx);
825
+ return scanned
826
+ .map((t) => ({ when: t.when, files: [...t.files.entries()].map(([path, writes]) => ({ path, writes })).slice(0, 50) }))
827
+ .slice(-20);
828
+ }
829
+
830
+ /** One provider, one model, one throwaway session — is it alive? */
831
+ async probeModel(provider: string, modelId: string): Promise<{ ok: boolean; detail: string }> {
832
+ if (!provider || !modelId) throw invalid("provider and modelId are required");
833
+ const created: SessionRow[] = [];
834
+ try {
835
+ const summary = await this.create({ name: `probe-${provider}`, cwd: this.startDirs[0] ?? homedir(), model: { provider, modelId } });
836
+ const row = { ...summary, createdAt: Number(summary.createdAt) } as unknown as SessionRow;
837
+ created.push(row);
838
+ const h = await this.openAgent(row);
839
+ const c = (await h.conversation(1 as ConversationId, ctx))!;
840
+ const submission = await this.send(c, { content: "Reply with exactly: ok", whenBusy: "followUp", requestId: `probe-${Date.now()}` });
841
+ const result = await this.settled(h, submission.id as number, 45_000);
842
+ const text = result.status === "done" ? result.answer.text.trim() : "";
843
+ return {
844
+ ok: result.status === "done" && !!text,
845
+ detail: result.status === "done" ? (text || "empty answer").slice(0, 120) : ((result as { reason?: string }).reason ?? "no answer").slice(0, 120),
846
+ };
847
+ } catch (e) {
848
+ return { ok: false, detail: String((e as Error).message).slice(0, 160) };
849
+ } finally {
850
+ for (const r of created) void this.archive(r).catch(() => {});
851
+ }
852
+ }
853
+
764
854
  // ------------------------------------------------------------ agents
765
855
 
766
856
  private names(sessionId: string) {