@dashup/connector 0.1.5 → 0.1.7

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 +4 -2
  2. package/dist/index.js +160 -49
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -22,7 +22,7 @@ dashup alert --project PROJECT_ID --title "Dashup is connected"
22
22
 
23
23
  The connector automatically reads existing Claude Code, Codex, and OpenCode conversations alongside Zed workspaces and VS Code chat history. It honors the harness's existing data directory, authentication, and native session IDs. Native stores are opened read-only; discovery never launches an agent, creates or resumes a conversation, or changes editor settings. Only a session the phone starts runs an agent (see below). Opening the same supported harness session through Zed and its native history uses one Dashup thread when their actual session identities match. Matching directory names alone never merges local and SSH projects.
24
24
 
25
- A thread's title is the agent's own (a rename, Codex's stored title, Claude's title), else the first message the person typed: Codex's injected AGENTS.md and environment messages and Claude Code's slash commands are skipped, in titles and in the conversation, and a Codex subagent is named for its task. A thread's time is its last conversation message in the agent's own history; editor bookkeeping such as Zed's sidebar restamps never moves it. Subagent sessions (OpenCode children, Codex spawned agents) carry their parent thread. Tool calls are joined with their results into one step with status, duration and, for file edits, a unified diff. Saved history has unknown live status and read-only controls. File changes publish project metadata; only an approval, a question, an error or a finished turn sends an alert. A workspace becomes a project once it has a conversation. After every change the connector publishes its overview: every project, and the conversations Home shows (anything that needs the person or is working, then the newest 20). The phone renders Home from the overview alone and decodes a project's full snapshot only when a page opens it. Only a scan that read every source retires a project it published earlier and left off. Each phone read request reads a thread's native history once and publishes a snapshot of separately encrypted message fragments to the database. The phone reads bounded latest and earlier pages; realtime thread updates carry only summary and approval metadata. Native history remains the source, and an incomplete upload never replaces the previous published snapshot. Native live interfaces provide actual status and pending choices. Tool integrations can also queue explicit encrypted alerts with `alert`.
25
+ A thread's title is the agent's own (a rename, Codex's stored title, Claude's title), else the first message the person typed: Codex's injected AGENTS.md and environment messages and Claude Code's slash commands are skipped, in titles and in the conversation, and a Codex subagent is named for its task. A thread's time is its last conversation message in the agent's own history; editor bookkeeping such as Zed's sidebar restamps never moves it. Subagent sessions (OpenCode children, Codex spawned agents) carry their parent thread. Tool calls are joined with their results into one step with status, duration and, for file edits, a unified diff. Saved history has unknown live status; the phone can answer it by resuming it (see Starting sessions from the phone). File changes publish project metadata; only an approval, a question, an error or a finished turn sends an alert. A workspace becomes a project once it has a conversation. After every change the connector publishes its overview: every project, and the conversations Home shows (anything that needs the person or is working, then the newest 20). The phone renders Home from the overview alone and decodes a project's full snapshot only when a page opens it. Only a scan that read every source retires a project it published earlier and left off. Each phone read request reads a thread's native history once and publishes a snapshot of separately encrypted message fragments to the database. The phone reads bounded latest and earlier pages; realtime thread updates carry only summary and approval metadata. Native history remains the source, and an incomplete upload never replaces the previous published snapshot. Native live interfaces provide actual status and pending choices. Tool integrations can also queue explicit encrypted alerts with `alert`.
26
26
 
27
27
  Project paths, SSH details, thread content, approvals, and alerts are encrypted separately for every paired phone. The encrypted outbox survives a connector restart. Parcae handles realtime delivery and reconnect; replay counters are saved before commands execute. Revoking a pairing stops its viewer and discards its connector key and queued content.
28
28
 
@@ -53,7 +53,9 @@ Live discovery is bounded to 100 sessions per endpoint. OpenCode supplies the la
53
53
 
54
54
  ## Starting sessions from the phone
55
55
 
56
- The phone starts a session in a local project, or in a new folder inside one of the folders that already hold this computer's projects, with Claude Code, Codex or OpenCode: whichever of `claude`, `codex` and `opencode` runs here. The agent works with its own settings and permission rules, and asks the phone before anything they don't allow. Claude Code runs headless (`claude -p`) for each turn and resumes the session for the next; Codex threads start in Codex's own app-server daemon (`codex app-server daemon start`) and outlive the connector; OpenCode sessions run on an `opencode serve` the connector starts for that folder. Claude Code and OpenCode sessions started this way can't be answered once the connector restarts. A start expires with other commands after 60 seconds.
56
+ The phone starts a session in a local project, or in a new folder inside one of the folders that already hold this computer's projects, with Claude Code, Codex or OpenCode: whichever of `claude`, `codex` and `opencode` runs here. The agent works with its own settings and permission rules, and asks the phone before anything they don't allow. Claude Code runs headless (`claude -p`) for each turn and resumes the session for the next; Codex threads start in Codex's own app-server daemon (`codex app-server daemon start`) and outlive the connector; OpenCode sessions run on an `opencode serve` the connector starts for that folder. Claude Code's permission requests arrive with the command, or the diff an edit or write would make.
57
+
58
+ A saved conversation is answered the same way when its agent runs here, its project is local, and nothing else has it open: Claude Code resumes it (`claude -p --resume`), Codex loads it in its daemon (`thread/resume`), and OpenCode serves its folder. One a native Codex process owns takes queued messages instead. A start expires with other commands after 60 seconds.
57
59
 
58
60
  ## Optional ACP wrapper
59
61
 
package/dist/index.js CHANGED
@@ -4017,7 +4017,7 @@ import * as ui from "@clack/prompts";
4017
4017
  import { z } from "zod";
4018
4018
 
4019
4019
  // package.json
4020
- var version = "0.1.5";
4020
+ var version = "0.1.7";
4021
4021
 
4022
4022
  // src/pair.ts
4023
4023
  var Invitation = z.object({
@@ -4644,17 +4644,17 @@ function step(id, name, input, result) {
4644
4644
  }
4645
4645
  var claudeHome = () => process.env.CLAUDE_CONFIG_DIR ?? join2(homedir2(), ".claude");
4646
4646
  var claudeMessage = (row) => (row.type === "user" || row.type === "assistant") && !row.isSidechain;
4647
- function claudeChange(change) {
4648
- if (typeof change?.filePath !== "string") return void 0;
4649
- const hunks = list(change.structuredPatch);
4647
+ function claudeChange(change2) {
4648
+ if (typeof change2?.filePath !== "string") return void 0;
4649
+ const hunks = list(change2.structuredPatch);
4650
4650
  if (!hunks.length)
4651
- return change.type === "create" && typeof change.content === "string" ? added(change.filePath, change.content) : void 0;
4651
+ return change2.type === "create" && typeof change2.content === "string" ? added(change2.filePath, change2.content) : void 0;
4652
4652
  return [
4653
- `--- ${change.filePath}`,
4654
- `+++ ${change.filePath}`,
4655
- ...hunks.flatMap((hunk) => [
4656
- `@@ -${hunk?.oldStart},${hunk?.oldLines} +${hunk?.newStart},${hunk?.newLines} @@`,
4657
- ...list(hunk?.lines)
4653
+ `--- ${change2.filePath}`,
4654
+ `+++ ${change2.filePath}`,
4655
+ ...hunks.flatMap((hunk2) => [
4656
+ `@@ -${hunk2?.oldStart},${hunk2?.oldLines} +${hunk2?.newStart},${hunk2?.newLines} @@`,
4657
+ ...list(hunk2?.lines)
4658
4658
  ])
4659
4659
  ].join("\n");
4660
4660
  }
@@ -16677,12 +16677,12 @@ var Client = class {
16677
16677
  this.requests.set(id, result);
16678
16678
  return result;
16679
16679
  }
16680
- // Starts a thread in `cwd` with Codex's own settings, then its first turn.
16681
- async start(cwd, text3) {
16680
+ // Starts a thread in `cwd` with Codex's own settings, or loads a saved one, then a turn.
16681
+ async start(cwd, text3, saved) {
16682
16682
  await this.connect();
16683
16683
  if (!this.controls) throw new Error("Update Codex to start threads from Dashup");
16684
- const id = (await this.call("thread/start", { cwd })).thread?.id;
16685
- if (typeof id !== "string") throw new Error("Codex did not start a thread");
16684
+ const id = saved ? (await this.call("thread/resume", { threadId: saved, excludeTurns: true })).thread?.id : (await this.call("thread/start", { cwd })).thread?.id;
16685
+ if (typeof id !== "string") throw new Error("Codex did not open the thread");
16686
16686
  await this.call("turn/start", { threadId: id, input: [{ type: "text", text: text3, text_elements: [] }] });
16687
16687
  this.changed();
16688
16688
  return id;
@@ -16771,8 +16771,8 @@ var Codex = class {
16771
16771
  clients = /* @__PURE__ */ new Map();
16772
16772
  // Control sockets of daemons this connector started, which discovery may not look for.
16773
16773
  started = /* @__PURE__ */ new Set();
16774
- // Codex's own daemon runs the threads Dashup starts, so they outlive this connector.
16775
- async start(cwd, text3) {
16774
+ // Codex's own daemon runs the threads Dashup starts or resumes, so they outlive this connector.
16775
+ async start(cwd, text3, saved) {
16776
16776
  const path = process.env.DASHUP_CODEX_SOCKET || join4(codexHome(), ...SOCKET_PARTS);
16777
16777
  await promisify3(execFile3)("codex", ["app-server", "daemon", "start"], {
16778
16778
  timeout: 6e4,
@@ -16784,7 +16784,10 @@ var Codex = class {
16784
16784
  client = new Client(path, this.changed);
16785
16785
  this.clients.set(path, client);
16786
16786
  }
16787
- return client.start(cwd, text3);
16787
+ return client.start(cwd, text3, saved);
16788
+ }
16789
+ async resume(id, cwd, text3) {
16790
+ await this.start(cwd, text3, id);
16788
16791
  }
16789
16792
  async refresh() {
16790
16793
  const paths = /* @__PURE__ */ new Set([...await sockets(), ...this.started]);
@@ -16813,6 +16816,7 @@ var Codex = class {
16813
16816
 
16814
16817
  // src/harness/headless.ts
16815
16818
  import { randomUUID as randomUUID3 } from "node:crypto";
16819
+ import { readFileSync as readFileSync3 } from "node:fs";
16816
16820
  import { createInterface as createInterface3 } from "node:readline";
16817
16821
  import { spawn as spawn3 } from "node:child_process";
16818
16822
  var CHOICES = [
@@ -16822,12 +16826,63 @@ var CHOICES = [
16822
16826
  var EDITS = /* @__PURE__ */ new Set(["Edit", "MultiEdit", "NotebookEdit", "Write"]);
16823
16827
  var MAX_REQUESTS = 1e3;
16824
16828
  var NOTIFY_MS = 100;
16829
+ function hunk(before, after, offset = 0) {
16830
+ const old = before ? before.replace(/\n$/, "").split("\n") : [];
16831
+ const next = after ? after.replace(/\n$/, "").split("\n") : [];
16832
+ let start = 0;
16833
+ while (start < old.length && start < next.length && old[start] === next[start]) start++;
16834
+ let end = 0;
16835
+ while (end < old.length - start && end < next.length - start && old[old.length - 1 - end] === next[next.length - 1 - end])
16836
+ end++;
16837
+ const removed = old.slice(start, old.length - end);
16838
+ const added2 = next.slice(start, next.length - end);
16839
+ return {
16840
+ lines: [
16841
+ `@@ -${offset + start + 1},${removed.length} +${offset + start + 1},${added2.length} @@`,
16842
+ ...old.slice(Math.max(0, start - 3), start).map((line) => ` ${line}`),
16843
+ ...removed.map((line) => `-${line}`),
16844
+ ...added2.map((line) => `+${line}`),
16845
+ ...old.slice(old.length - end, old.length - end + 3).map((line) => ` ${line}`)
16846
+ ],
16847
+ added: added2.length,
16848
+ deleted: removed.length
16849
+ };
16850
+ }
16851
+ function change(tool, input) {
16852
+ const path = input.file_path;
16853
+ if (typeof path !== "string") return void 0;
16854
+ let current = "";
16855
+ try {
16856
+ current = readFileSync3(path, "utf8");
16857
+ } catch {
16858
+ }
16859
+ const edit = (value2) => {
16860
+ const before = String(value2?.old_string ?? "");
16861
+ const at = before ? current.indexOf(before) : -1;
16862
+ return hunk(
16863
+ before,
16864
+ String(value2?.new_string ?? ""),
16865
+ at < 0 ? 0 : current.slice(0, at).split("\n").length - 1
16866
+ );
16867
+ };
16868
+ const hunks = tool === "Write" && typeof input.content === "string" ? [hunk(current, input.content)] : tool === "Edit" ? [edit(input)] : tool === "MultiEdit" && Array.isArray(input.edits) ? input.edits.map(edit) : [];
16869
+ if (!hunks.length) return void 0;
16870
+ const diff = [`--- ${path}`, `+++ ${path}`, ...hunks.flatMap((item) => item.lines)].join("\n");
16871
+ if (Buffer.byteLength(diff) > MAX_PREVIEW) return void 0;
16872
+ return {
16873
+ path,
16874
+ diff,
16875
+ added: hunks.reduce((sum, item) => sum + item.added, 0),
16876
+ deleted: hunks.reduce((sum, item) => sum + item.deleted, 0)
16877
+ };
16878
+ }
16825
16879
  function approval2(id, request2) {
16826
16880
  const input = request2.input ?? {};
16827
16881
  const tool = String(request2.display_name ?? request2.tool_name ?? "a tool");
16882
+ const diff = change(request2.tool_name, input);
16828
16883
  let detail = [
16829
16884
  typeof request2.description === "string" ? request2.description : "",
16830
- typeof input.command === "string" ? input.command : JSON.stringify(input, null, 2)
16885
+ typeof input.command === "string" ? input.command : diff ? "" : JSON.stringify(input, null, 2)
16831
16886
  ].filter(Boolean).join("\n\n");
16832
16887
  if (Buffer.byteLength(detail) > MAX_PREVIEW)
16833
16888
  detail = Buffer.from(detail).subarray(0, MAX_PREVIEW).toString("utf8");
@@ -16835,28 +16890,30 @@ function approval2(id, request2) {
16835
16890
  id,
16836
16891
  title: request2.tool_name === "Bash" ? "Allow command?" : EDITS.has(request2.tool_name) ? "Allow file changes?" : `Allow ${tool}?`,
16837
16892
  detail,
16893
+ ...diff ? { change: diff } : {},
16838
16894
  choices: CHOICES.map((choice) => ({ ...choice }))
16839
16895
  };
16840
16896
  }
16841
16897
  var Session = class {
16842
- constructor(id, cwd, title2, changed) {
16898
+ // A resumed session is `saved` and keeps the title its history gives.
16899
+ constructor(id, cwd, title2, changed, saved = false) {
16843
16900
  this.id = id;
16844
16901
  this.cwd = cwd;
16845
16902
  this.title = title2;
16846
16903
  this.changed = changed;
16904
+ this.saved = saved;
16847
16905
  }
16848
16906
  id;
16849
16907
  cwd;
16850
16908
  title;
16851
16909
  changed;
16910
+ saved;
16852
16911
  messages = [];
16853
16912
  status = "idle";
16854
16913
  updatedAt = (/* @__PURE__ */ new Date()).toISOString();
16855
16914
  turn;
16856
16915
  // Messages written to this turn's process that Claude Code has not finished answering.
16857
16916
  open = 0;
16858
- // Once Claude Code has saved the session, later turns resume it.
16859
- saved = false;
16860
16917
  approvals = /* @__PURE__ */ new Map();
16861
16918
  requests = /* @__PURE__ */ new Map();
16862
16919
  write(value2) {
@@ -17024,6 +17081,18 @@ var Headless = class {
17024
17081
  }
17025
17082
  return session.id;
17026
17083
  }
17084
+ // Continues a saved session with a message; Claude Code resumes it from its own transcript.
17085
+ async resume(id, cwd, text3) {
17086
+ const known = this.sessions.get(id);
17087
+ const session = known ?? new Session(id, cwd, void 0, this.soon, true);
17088
+ this.sessions.set(id, session);
17089
+ try {
17090
+ await session.say(text3);
17091
+ } catch (error) {
17092
+ if (!known) this.sessions.delete(id);
17093
+ throw error;
17094
+ }
17095
+ }
17027
17096
  async refresh() {
17028
17097
  return [...this.sessions.values()].map((session) => session.live());
17029
17098
  }
@@ -17804,7 +17873,7 @@ function permissionApproval(permission, history, cwd) {
17804
17873
  const command2 = typeof input?.command === "string" ? input.command : metadata2?.command;
17805
17874
  const path = typeof metadata2?.filepath === "string" ? metadata2.filepath : input?.filePath;
17806
17875
  const diff = typeof metadata2?.diff === "string" ? metadata2.diff : "";
17807
- const change = typeof path === "string" && path.trim() && diff.trim() ? { path, diff } : void 0;
17876
+ const change2 = typeof path === "string" && path.trim() && diff.trim() ? { path, diff } : void 0;
17808
17877
  const patterns = Array.isArray(permission.patterns) ? permission.patterns.filter((value2) => typeof value2 === "string") : [];
17809
17878
  const always = Array.isArray(permission.always) ? permission.always.filter((value2) => typeof value2 === "string") : [];
17810
17879
  let detail = [
@@ -17812,7 +17881,7 @@ function permissionApproval(permission, history, cwd) {
17812
17881
  ${command2}` : "",
17813
17882
  hasInput ? `Tool input:
17814
17883
  ${JSON.stringify(input, null, 2)}` : "",
17815
- diff && !change ? `Proposed diff:
17884
+ diff && !change2 ? `Proposed diff:
17816
17885
  ${diff}` : "",
17817
17886
  `Working directory: ${cwd}`,
17818
17887
  patterns.length ? `Permission scope:
@@ -17821,10 +17890,10 @@ ${patterns.join("\n")}` : "",
17821
17890
  ${always.join("\n")}` : ""
17822
17891
  ].filter(Boolean).join("\n\n");
17823
17892
  const oversized = Buffer.byteLength(detail) > MAX_PREVIEW || Buffer.byteLength(diff) > MAX_PREVIEW;
17824
- const unavailable = !(hasInput || change || typeof command2 === "string" && command2.trim()) || oversized;
17893
+ const unavailable = !(hasInput || change2 || typeof command2 === "string" && command2.trim()) || oversized;
17825
17894
  if (oversized) {
17826
17895
  detail = Buffer.from(detail).subarray(0, MAX_PREVIEW).toString("utf8");
17827
- if (change) change.diff = Buffer.from(diff).subarray(0, MAX_PREVIEW).toString("utf8");
17896
+ if (change2) change2.diff = Buffer.from(diff).subarray(0, MAX_PREVIEW).toString("utf8");
17828
17897
  }
17829
17898
  if (unavailable) detail += `
17830
17899
 
@@ -17834,7 +17903,7 @@ ${SCREEN_PREVIEW}`;
17834
17903
  title: `Allow ${String(permission.permission ?? "tool")}?`,
17835
17904
  detail,
17836
17905
  choices: PERMISSION_CHOICES.filter((choice) => !unavailable || choice.id === "reject").map((choice) => ({ ...choice })),
17837
- ...change ? { change } : {}
17906
+ ...change2 ? { change: change2 } : {}
17838
17907
  };
17839
17908
  }
17840
17909
  function promptContext(history) {
@@ -17897,14 +17966,20 @@ var Server = class {
17897
17966
  this.requests.set(id, pending);
17898
17967
  return pending;
17899
17968
  }
17900
- // A session Dashup starts takes OpenCode's default agent and model for its folder.
17901
- async start(cwd, text3) {
17902
- const id = (await this.request("/session", cwd, {}))?.id;
17903
- if (typeof id !== "string") throw new Error("OpenCode did not start a session");
17969
+ // A message to one of this folder's sessions, in the agent and model it last used; a new
17970
+ // session takes OpenCode's defaults.
17971
+ async prompt(id, cwd, text3) {
17972
+ const existing = await this.request(`/session/${encodeURIComponent(id)}/message?limit=200`, cwd);
17904
17973
  await this.request(`/session/${encodeURIComponent(id)}/prompt_async`, cwd, {
17974
+ ...promptContext(existing),
17905
17975
  parts: [{ type: "text", text: text3 }]
17906
17976
  });
17907
17977
  this.changed();
17978
+ }
17979
+ async start(cwd, text3) {
17980
+ const id = (await this.request("/session", cwd, {}))?.id;
17981
+ if (typeof id !== "string") throw new Error("OpenCode did not start a session");
17982
+ await this.prompt(id, cwd, text3);
17908
17983
  return id;
17909
17984
  }
17910
17985
  async read() {
@@ -18139,7 +18214,8 @@ var OpenCode = class {
18139
18214
  servers = /* @__PURE__ */ new Map();
18140
18215
  // Servers this connector started, by folder.
18141
18216
  started = /* @__PURE__ */ new Map();
18142
- async start(cwd, text3) {
18217
+ // The server this connector runs for a folder, started when first needed.
18218
+ async served(cwd) {
18143
18219
  let served = this.started.get(cwd);
18144
18220
  if (!served) this.started.set(cwd, served = serve(cwd));
18145
18221
  const endpoint = await served.endpoint.catch((error) => {
@@ -18151,7 +18227,13 @@ var OpenCode = class {
18151
18227
  server = new Server(endpoint, this.changed);
18152
18228
  this.servers.set(endpoint.url, server);
18153
18229
  }
18154
- return server.start(cwd, text3);
18230
+ return server;
18231
+ }
18232
+ async start(cwd, text3) {
18233
+ return (await this.served(cwd)).start(cwd, text3);
18234
+ }
18235
+ async resume(id, cwd, text3) {
18236
+ await (await this.served(cwd)).prompt(id, cwd, text3);
18155
18237
  }
18156
18238
  async refresh() {
18157
18239
  const discovered = await endpoints().catch(() => []);
@@ -18245,6 +18327,14 @@ var LiveHarnesses = class {
18245
18327
  text3
18246
18328
  );
18247
18329
  }
18330
+ // Continues a saved session with a message, and resolves once its agent takes it.
18331
+ resume(harness, id, cwd, text3) {
18332
+ return { claude: this.headless, codex: this.codex, opencode: this.opencode }[harness].resume(
18333
+ id,
18334
+ cwd,
18335
+ text3
18336
+ );
18337
+ }
18248
18338
  watch(onChange) {
18249
18339
  this.listeners.add(onChange);
18250
18340
  if (!this.timer && process.env.DASHUP_LIVE_DISCOVERY !== "0") {
@@ -18276,7 +18366,7 @@ import { randomUUID as randomUUID5 } from "node:crypto";
18276
18366
  import { spawn as spawn5 } from "node:child_process";
18277
18367
  import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
18278
18368
  import { join as join8, dirname as dirname5, basename as basename3 } from "node:path";
18279
- import { statSync as statSync2, existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync3, readlinkSync, realpathSync as realpathSync2 } from "node:fs";
18369
+ import { statSync as statSync2, existsSync as existsSync5, readdirSync as readdirSync3, readFileSync as readFileSync4, readlinkSync, realpathSync as realpathSync2 } from "node:fs";
18280
18370
  var CODEX_VERSION = "0.160.1";
18281
18371
  var REQUEST_MS5 = 1e4;
18282
18372
  var MAX_BYTES4 = 1024 * 1024;
@@ -18298,7 +18388,7 @@ function packageVersion(executable) {
18298
18388
  for (let depth = 0; depth < 8; depth++, directory2 = dirname5(directory2)) {
18299
18389
  const path = join8(directory2, "package.json");
18300
18390
  if (!existsSync5(path)) continue;
18301
- const value2 = JSON.parse(readFileSync3(path, "utf8"));
18391
+ const value2 = JSON.parse(readFileSync4(path, "utf8"));
18302
18392
  if (value2.name?.startsWith("@openai/codex-")) return value2.version;
18303
18393
  }
18304
18394
  return void 0;
@@ -18308,7 +18398,7 @@ function processOwners(pid) {
18308
18398
  if (process.getuid && statSync2(root).uid !== process.getuid()) return [];
18309
18399
  const executable = realpathSync2.native(join8(root, "exe"));
18310
18400
  if (basename3(executable) !== "codex" || packageVersion(executable) !== CODEX_VERSION) return [];
18311
- const status2 = readFileSync3(join8(root, "stat"), "utf8");
18401
+ const status2 = readFileSync4(join8(root, "stat"), "utf8");
18312
18402
  const start = status2.slice(status2.lastIndexOf(")") + 2).split(" ")[19];
18313
18403
  if (!start || !/^\d+$/.test(start)) return [];
18314
18404
  const fds = readdirSync3(join8(root, "fd")).slice(0, MAX_FILES);
@@ -18324,7 +18414,7 @@ function processOwners(pid) {
18324
18414
  const thread = basename3(path, ".lock");
18325
18415
  if (!THREAD_ID.test(thread)) return [];
18326
18416
  try {
18327
- const lock = readFileSync3(join8(root, "fdinfo", fd), "utf8");
18417
+ const lock = readFileSync4(join8(root, "fdinfo", fd), "utf8");
18328
18418
  if (!/FLOCK\s+ADVISORY\s+WRITE/.test(lock)) return [];
18329
18419
  const home = realpathSync2.native(dirname5(dirname5(path)));
18330
18420
  const directory2 = statSync2(home);
@@ -18725,7 +18815,8 @@ var Connector = class {
18725
18815
  this.nativeSnapshots.set(thread.id, structuredClone(this.threadState(project, thread)));
18726
18816
  }
18727
18817
  this.refreshQueues();
18728
- void this.findAgents();
18818
+ this.refreshResumes();
18819
+ void this.findAgents().then(() => this.queueChange());
18729
18820
  this.stopWatching = this.discovery.watch(this.rescan);
18730
18821
  this.releases.push(this.harnesses.watch(this.rescan));
18731
18822
  this.releases.push(
@@ -18954,7 +19045,6 @@ var Connector = class {
18954
19045
  // Agents change only when someone installs or removes one, so a refresh asks again.
18955
19046
  async findAgents() {
18956
19047
  this.startable = await this.harnesses.agents();
18957
- this.refreshOverview();
18958
19048
  }
18959
19049
  async listProjects(pair) {
18960
19050
  const overview = this.overview();
@@ -18985,6 +19075,7 @@ var Connector = class {
18985
19075
  const native = await this.harnesses.refresh();
18986
19076
  const live = native.map((session) => ({ ...this.discovery.observe(session), session }));
18987
19077
  this.refreshQueues();
19078
+ this.refreshResumes();
18988
19079
  for (const agent of this.agents.values()) if (!agent.socket.destroyed) this.adopt(agent);
18989
19080
  const current = new Set(live.map(({ thread }) => thread.id));
18990
19081
  for (const [id, snapshot] of this.nativeSnapshots) {
@@ -19036,6 +19127,17 @@ var Connector = class {
19036
19127
  for (const pair of this.activePairs()) await this.listProjects(pair);
19037
19128
  await this.publishQueueReceipts();
19038
19129
  }
19130
+ // A saved session nothing else has open continues headless when its agent runs here. One a
19131
+ // native process owns takes queued messages instead.
19132
+ refreshResumes() {
19133
+ for (const project of this.discovery.projects.values())
19134
+ for (const thread of project.threads) {
19135
+ const location2 = this.discovery.locations.get(thread.id);
19136
+ const harness = thread.agent?.harness;
19137
+ if (location2?.history && !location2.live && !project.remote && !thread.capabilities.queue && harness && harness !== "other" && this.startable.includes(harness))
19138
+ thread.capabilities = { ...thread.capabilities, send: true };
19139
+ }
19140
+ }
19039
19141
  refreshQueues() {
19040
19142
  this.nativeQueue.refresh();
19041
19143
  for (const project of this.discovery.projects.values())
@@ -19267,9 +19369,15 @@ var Connector = class {
19267
19369
  throw new Error(
19268
19370
  "This session has no live reply connection. Open Screen to use the existing agent."
19269
19371
  );
19270
- const native = this.discovery.locations.get(thread.id)?.live;
19271
- if (native?.send) return native.send(command2.requestId, command2.action.text);
19372
+ const location2 = this.discovery.locations.get(thread.id);
19373
+ if (location2?.live?.send) return location2.live.send(command2.requestId, command2.action.text);
19272
19374
  const agent = this.attached(thread);
19375
+ const saved = thread.agent;
19376
+ if (!agent && location2?.history && saved && saved.harness !== "other") {
19377
+ await this.harnesses.resume(saved.harness, saved.sessionId, project.path, command2.action.text);
19378
+ await this.changed();
19379
+ return;
19380
+ }
19273
19381
  if (!agent || agent.socket.destroyed || agent.status === "working")
19274
19382
  throw new Error("Wait for the current ACP turn to finish");
19275
19383
  return commandAcp(agent, command2.requestId, { kind: "prompt", text: command2.action.text });
@@ -19380,6 +19488,9 @@ import { dirname as dirname6, join as join10, relative, sep } from "node:path";
19380
19488
  var run4 = promisify7(execFile7);
19381
19489
  var xml = (value2) => value2.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
19382
19490
  var NODE_FLAGS = ["--disable-warning=ExperimentalWarning"];
19491
+ var systemctl = (...args2) => run4("systemctl", ["--user", ...args2], {
19492
+ env: { ...process.env, XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR ?? `/run/user/${userInfo().uid}` }
19493
+ });
19383
19494
  var serviceFile = () => process.platform === "darwin" ? join10(homedir6(), "Library/LaunchAgents/com.dashup.connector.plist") : process.platform === "win32" ? join10(
19384
19495
  process.env.APPDATA ?? join10(homedir6(), "AppData/Roaming"),
19385
19496
  "Microsoft/Windows/Start Menu/Programs/Startup/Dashup.vbs"
@@ -19441,14 +19552,14 @@ async function installStartup() {
19441
19552
  } else if (process.platform === "win32") {
19442
19553
  spawn6("wscript.exe", [path], { detached: true, stdio: "ignore" }).unref();
19443
19554
  } else {
19444
- await run4("systemctl", ["--user", "daemon-reload"]);
19445
- await run4("systemctl", ["--user", "enable", "dashup.service"]);
19446
- await run4("systemctl", ["--user", "restart", "dashup.service"]);
19447
- if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
19448
- return run4("loginctl", ["enable-linger"]).then(
19449
- () => void 0,
19450
- () => `It stops when you log out. To keep it running, run sudo loginctl enable-linger ${userInfo().username}.`
19451
- );
19555
+ const note = process.env.DISPLAY || process.env.WAYLAND_DISPLAY ? void 0 : await run4("loginctl", ["enable-linger"]).then(
19556
+ () => void 0,
19557
+ () => `It stops when you log out. To keep it running, run sudo loginctl enable-linger ${userInfo().username}.`
19558
+ );
19559
+ await systemctl("daemon-reload");
19560
+ await systemctl("enable", "dashup.service");
19561
+ await systemctl("restart", "dashup.service");
19562
+ return note;
19452
19563
  }
19453
19564
  }
19454
19565
  async function uninstallStartup() {
@@ -19456,7 +19567,7 @@ async function uninstallStartup() {
19456
19567
  if (process.platform === "darwin") await run4("launchctl", ["unload", path]).catch(() => {
19457
19568
  });
19458
19569
  else if (process.platform !== "win32")
19459
- await run4("systemctl", ["--user", "disable", "dashup.service"]).catch(() => {
19570
+ await systemctl("disable", "dashup.service").catch(() => {
19460
19571
  });
19461
19572
  await rm(path, { force: true });
19462
19573
  await rm(join10(dirname6(configPath()), "app"), { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dashup/connector",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "dashup": "dist/index.js"
@@ -29,11 +29,11 @@
29
29
  "tsup": "^8.5.1",
30
30
  "tsx": "^4.22.4",
31
31
  "typescript": "^5.9.3",
32
- "@dashup/models": "0.1.0",
33
- "@dashup/protocol": "0.1.0",
34
32
  "@dashup/typescript-config": "0.0.0",
33
+ "@dashup/models": "0.1.0",
35
34
  "@parcae/lynx": "0.1.0",
36
- "@parcae/sdk": "0.8.1"
35
+ "@parcae/sdk": "0.8.1",
36
+ "@dashup/protocol": "0.1.0"
37
37
  },
38
38
  "scripts": {
39
39
  "dashup": "tsx src/index.ts",
@@ -41,7 +41,7 @@
41
41
  "start": "node dist/index.js watch",
42
42
  "build": "tsup",
43
43
  "check-types": "tsc --noEmit",
44
- "test": "node --import tsx --test tests/connector.test.ts tests/history.test.ts tests/harness-history.test.ts tests/harness-live.test.ts tests/harness-queue.test.ts tests/harness-vscode.test.ts tests/install.test.ts tests/stop.test.ts tests/viewer.test.ts",
44
+ "test": "node --import tsx --test tests/connector.test.ts tests/history.test.ts tests/harness-history.test.ts tests/harness-live.test.ts tests/harness-queue.test.ts tests/harness-vscode.test.ts tests/install.test.ts tests/headless.test.ts tests/stop.test.ts tests/viewer.test.ts",
45
45
  "test:workflow": "node --import tsx tests/workflow.test.ts"
46
46
  }
47
47
  }