@fancyboi999/open-tag-daemon 0.7.1 → 0.8.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.
@@ -3047,7 +3047,7 @@ var {
3047
3047
  } = import_index.default;
3048
3048
 
3049
3049
  // src/cli/index.ts
3050
- import { readFile, writeFile, mkdir } from "node:fs/promises";
3050
+ import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
3051
3051
  import { basename, join } from "node:path";
3052
3052
 
3053
3053
  // src/log.ts
@@ -3110,6 +3110,7 @@ var log = createLogger("cli");
3110
3110
  var BASE = process.env.OPEN_TAG_SERVER_URL ?? "http://localhost:7777";
3111
3111
  var KEY = process.env.OPEN_TAG_AGENT_TOKEN ?? process.env.OPEN_TAG_MACHINE_KEY ?? process.env.OPEN_TAG_API_KEY ?? "poc-secret-key";
3112
3112
  var AGENT = process.env.OPEN_TAG_AGENT_ID ?? "";
3113
+ var TURN_FILE = process.env.OPEN_TAG_TURN_FILE ?? "";
3113
3114
  function headers() {
3114
3115
  return { authorization: `Bearer ${KEY}`, "x-agent-id": AGENT, "content-type": "application/json" };
3115
3116
  }
@@ -3147,12 +3148,28 @@ function readStdin() {
3147
3148
  process.stdin.on("end", () => resolve(d));
3148
3149
  });
3149
3150
  }
3151
+ function targetFromText(text) {
3152
+ const m = /^\[target=([^\s\]]+)/.exec(text);
3153
+ return m?.[1] ?? null;
3154
+ }
3155
+ async function recordTurnEvent(event) {
3156
+ if (!TURN_FILE) return;
3157
+ try {
3158
+ await appendFile(TURN_FILE, JSON.stringify({ ...event, at: Date.now() }) + "\n");
3159
+ } catch {
3160
+ }
3161
+ }
3150
3162
  var program2 = new Command();
3151
3163
  program2.name("open-tag").description("open-tag agent CLI").version("0.1.0");
3152
3164
  var message = program2.command("message").description("message send/receive");
3153
3165
  message.command("check").description("non-blocking check for new messages").action(async () => {
3154
3166
  const d = await api("GET", "/agent-api/message/check");
3155
- if (!d.messages?.length) return console.log("No new messages.");
3167
+ if (!d.messages?.length) {
3168
+ await recordTurnEvent({ type: "check", count: 0 });
3169
+ return console.log("No new messages.");
3170
+ }
3171
+ const targets = Array.from(new Set((d.messages ?? []).map((m) => targetFromText(String(m.text ?? ""))).filter(Boolean)));
3172
+ await recordTurnEvent({ type: "check", count: d.messages.length, target: targets[targets.length - 1] ?? null, targets });
3156
3173
  for (const m of d.messages) console.log(m.text);
3157
3174
  console.log("No more new messages.");
3158
3175
  });
@@ -3166,7 +3183,11 @@ message.command("send").description("send a message (body read from stdin); if n
3166
3183
  process.exit(1);
3167
3184
  }
3168
3185
  const d = await api("POST", "/agent-api/message/send", { target: opts.target, content, attachmentIds, sendDraft });
3169
- if (d.held) return console.log(d.text);
3186
+ if (d.held) {
3187
+ await recordTurnEvent({ type: "held", target: opts.target });
3188
+ return console.log(d.text);
3189
+ }
3190
+ await recordTurnEvent({ type: "send", target: opts.target, id: d.id, seq: d.seq });
3170
3191
  console.log(`Sent to ${opts.target} (msg ${String(d.id).slice(0, 8)}, seq ${d.seq})`);
3171
3192
  });
3172
3193
  message.command("read").description("read channel history (supports anchor flags --before/--after/--around: message short/full id or seq, jumps to the specified context)").requiredOption("--channel <channel>").option("--limit <n>", "number of messages", "50").option("--around <idOrSeq>", "fetch context centered on this message").option("--before <idOrSeq>", "fetch messages before this one").option("--after <idOrSeq>", "fetch messages after this one").action(async (opts) => {
@@ -3175,6 +3196,7 @@ message.command("read").description("read channel history (supports anchor flags
3175
3196
  else if (opts.before) q.set("before", opts.before);
3176
3197
  else if (opts.after) q.set("after", opts.after);
3177
3198
  const d = await api("GET", `/agent-api/message/read?${q}`);
3199
+ await recordTurnEvent({ type: "read", target: opts.channel, count: d.messages?.length ?? 0 });
3178
3200
  for (const m of d.messages ?? []) console.log(m.text);
3179
3201
  });
3180
3202
  message.command("react").description("add or remove a reaction emoji on a message (lightweight feedback)").requiredOption("--message-id <id>").requiredOption("--emoji <emoji>", "e.g. \u{1F44D} \u2705").option("--remove", "remove the reaction instead of adding it").action(async (opts) => {
package/dist/cli.mjs CHANGED
@@ -3725,7 +3725,7 @@ try {
3725
3725
  // src/daemon/index.ts
3726
3726
  import os4 from "node:os";
3727
3727
  import fs3 from "node:fs";
3728
- import path12 from "node:path";
3728
+ import path14 from "node:path";
3729
3729
 
3730
3730
  // node_modules/ws/wrapper.mjs
3731
3731
  var import_stream = __toESM(require_stream(), 1);
@@ -3913,8 +3913,8 @@ var Connection = class {
3913
3913
  };
3914
3914
 
3915
3915
  // src/daemon/agentManager.ts
3916
- import { mkdir, writeFile, readFile, access, rm } from "node:fs/promises";
3917
- import path10 from "node:path";
3916
+ import { mkdir, writeFile, readFile as readFile2, access, rm } from "node:fs/promises";
3917
+ import path11 from "node:path";
3918
3918
  import os2 from "node:os";
3919
3919
 
3920
3920
  // src/daemon/prompt.ts
@@ -4033,6 +4033,7 @@ ${c.description}. This may evolve.` : ""}`;
4033
4033
  }
4034
4034
  var STARTUP_NUDGE = "You just started \u2014 someone messaged you. FIRST run `open-tag message check` to read the message(s) waiting for you, handle them fully, reply with `open-tag message send`, then stop.";
4035
4035
  var RESUME_NUDGE = "You were woken because new messages may be waiting. Run `open-tag message check` to read them, handle them, reply with `open-tag message send`, then stop.";
4036
+ var ONE_SHOT_WAKE_NUDGE = "You were woken by a new open-tag delivery. FIRST run `open-tag message check` now, handle the pending message(s), and send exactly one reply with `open-tag message send`. Do not end this turn with stdout only; only `open-tag message send` reaches the human.";
4036
4037
  function inboxNotice(o) {
4037
4038
  const plural = (n) => n === 1 ? "" : "s";
4038
4039
  const changed = o.changedTargets ?? 1;
@@ -4043,7 +4044,7 @@ function inboxNotice(o) {
4043
4044
  Inbox update: ${o.count} unread message${plural(o.count)} total; ${changed} changed target${plural(changed)}
4044
4045
  ${o.targetName} pending: ${o.count} message${plural(o.count)}${first} \xB7 latest @${o.from}${latest}${suffix}
4045
4046
  ]
4046
- Content-free signal \u2014 message bodies are withheld, not absent. Finish your current step, then run \`open-tag message check\` to read and handle. Never conclude "no work" from this notice alone.`;
4047
+ Content-free signal \u2014 message bodies are withheld, not absent. Finish your current step, then run \`open-tag message check\` to read and handle. If this notice is the only thing in your current turn, check now and reply with \`open-tag message send\`; do not finish with stdout only. Never conclude "no work" from this notice alone.`;
4047
4048
  }
4048
4049
 
4049
4050
  // src/daemon/memory.ts
@@ -5376,6 +5377,302 @@ var cursorRuntime = {
5376
5377
  }
5377
5378
  };
5378
5379
 
5380
+ // src/daemon/hermesRuntime.ts
5381
+ import { spawn as spawn8 } from "node:child_process";
5382
+ import { existsSync } from "node:fs";
5383
+ import { readFile, unlink } from "node:fs/promises";
5384
+ import { homedir, tmpdir } from "node:os";
5385
+ import path10 from "node:path";
5386
+ var MAX8 = 4e3;
5387
+ var clip8 = (s) => String(s ?? "").slice(0, MAX8);
5388
+ var FINAL_RESPONSE_MAX = 2400;
5389
+ function hermesProfile(model, runtimeConfig) {
5390
+ const configured = runtimeConfig?.profile;
5391
+ if (typeof configured === "string" && configured.trim()) return configured.trim();
5392
+ if (model && model !== "default") return model;
5393
+ return "default";
5394
+ }
5395
+ function hermesProfileRoots(home = homedir(), env = process.env) {
5396
+ return [env.HERMES_PROFILE_DIR, path10.join(home, ".hermes", "profiles")].filter((v) => !!v);
5397
+ }
5398
+ function buildHermesPrompt(message, opts) {
5399
+ return [
5400
+ "[OpenTag runtime context]",
5401
+ `You are running as an OpenTag agent in this isolated workspace: ${opts.cwd}`,
5402
+ "Follow this OpenTag system prompt for collaboration, @mentions, and reporting:",
5403
+ opts.systemPrompt,
5404
+ "",
5405
+ "[OpenTag message]",
5406
+ message
5407
+ ].join("\n");
5408
+ }
5409
+ function buildHermesArgs(prompt, sessionId) {
5410
+ const args2 = ["chat", "-q", prompt, "-Q", "--source", "open-tag"];
5411
+ if (sessionId) args2.push("--resume", sessionId);
5412
+ return args2;
5413
+ }
5414
+ function parseHermesSessionId(stderr) {
5415
+ const matches = [...stderr.matchAll(/^session_id:\s*(\S+)\s*$/gm)];
5416
+ return matches.length ? matches[matches.length - 1][1] : null;
5417
+ }
5418
+ function isMissingHermesSession(stderr) {
5419
+ return /Session not found:/i.test(stderr);
5420
+ }
5421
+ function parseHermesTurnEvents(jsonl) {
5422
+ const state = { sent: false, held: false, engaged: false, target: null };
5423
+ for (const line of jsonl.split("\n")) {
5424
+ if (!line.trim()) continue;
5425
+ let evt;
5426
+ try {
5427
+ evt = JSON.parse(line);
5428
+ } catch {
5429
+ continue;
5430
+ }
5431
+ if (evt.type === "send") state.sent = true;
5432
+ if (evt.type === "held") state.held = true;
5433
+ if ((evt.type === "check" || evt.type === "read") && typeof evt.target === "string" && evt.target.trim()) {
5434
+ state.engaged = true;
5435
+ state.target = evt.target.trim();
5436
+ }
5437
+ }
5438
+ return state;
5439
+ }
5440
+ function cleanHermesStdout(stdout) {
5441
+ let lines = stdout.trim().split(/\r?\n/).map((line) => line.trimEnd());
5442
+ while (lines.length && !lines[0].trim()) lines.shift();
5443
+ while (lines.length && /^(⚠|Warning:)/.test(lines[0].trim())) lines.shift();
5444
+ while (lines.length && !lines[0].trim()) lines.shift();
5445
+ const content = lines.join("\n").trim();
5446
+ if (!content) return { ok: false, reason: "empty-stdout" };
5447
+ if (/^(No new messages\.|Sent to |Freshness hold:)/.test(content)) return { ok: false, reason: "cli-output" };
5448
+ if (/^(Error:|Code:|Exception:|Traceback\b|Unhandled\b)/i.test(content)) return { ok: false, reason: "error-output" };
5449
+ if (/^(┊|diff --git\b|@@\s+-|\+\+\+ |--- )/m.test(content) || /\na\/.+\s+→\s+b\/.+/.test(content)) {
5450
+ return { ok: false, reason: "diff-output" };
5451
+ }
5452
+ return { ok: true, target: "", content: content.slice(0, FINAL_RESPONSE_MAX) };
5453
+ }
5454
+ function hermesBridgeDecision(stdout, state) {
5455
+ if (state.sent) return { ok: false, reason: "already-sent" };
5456
+ if (state.held) return { ok: false, reason: "already-held" };
5457
+ if (!state.engaged || !state.target) return { ok: false, reason: "no-open-tag-read" };
5458
+ if (!/^(#|dm:|thread:)/.test(state.target)) return { ok: false, reason: "invalid-target" };
5459
+ const cleaned = cleanHermesStdout(stdout);
5460
+ if (!cleaned.ok) return cleaned;
5461
+ return { ok: true, target: state.target, content: cleaned.content };
5462
+ }
5463
+ async function responseJson(res) {
5464
+ try {
5465
+ return await res.json();
5466
+ } catch {
5467
+ return {};
5468
+ }
5469
+ }
5470
+ async function postHermesBridgeMessage(fetchImpl, serverUrl2, headers, target, content) {
5471
+ const url = `${serverUrl2}/agent-api/message/send`;
5472
+ const first = await fetchImpl(url, {
5473
+ method: "POST",
5474
+ headers,
5475
+ body: JSON.stringify({ target, content })
5476
+ });
5477
+ const firstBody = await responseJson(first);
5478
+ if (!first.ok) return { ok: false, status: first.status };
5479
+ if (!firstBody?.held) return { ok: true };
5480
+ const held = { ok: false, held: true, sentDraft: false };
5481
+ if (typeof firstBody.text === "string") held.text = firstBody.text;
5482
+ return held;
5483
+ }
5484
+ function hermesProfileHome(profile, home = homedir(), roots = hermesProfileRoots(home)) {
5485
+ if (!profile || profile === "default") return null;
5486
+ for (const root of roots) {
5487
+ const dir = path10.join(root, profile);
5488
+ if (existsSync(dir)) return dir;
5489
+ }
5490
+ return null;
5491
+ }
5492
+ function hermesRuntimeEnv(baseEnv, cwd, requestedProfile, home = homedir()) {
5493
+ const env = { ...baseEnv, PWD: cwd };
5494
+ delete env.NODE_OPTIONS;
5495
+ delete env.HERMES_HOME;
5496
+ delete env.HERMES_PROFILE;
5497
+ const profileHome = hermesProfileHome(requestedProfile, home, hermesProfileRoots(home, env));
5498
+ const profile = requestedProfile === "default" || profileHome ? requestedProfile : "default";
5499
+ if (profileHome) {
5500
+ env.HERMES_HOME = profileHome;
5501
+ env.HERMES_PROFILE = profile;
5502
+ }
5503
+ return { env, profile };
5504
+ }
5505
+ var HermesRun = class {
5506
+ constructor(opts, cb) {
5507
+ this.opts = opts;
5508
+ this.cb = cb;
5509
+ const requestedProfile = hermesProfile(opts.model, opts.runtimeConfig);
5510
+ const resolved = hermesRuntimeEnv(opts.env, opts.cwd, requestedProfile);
5511
+ this.env = resolved.env;
5512
+ this.profile = resolved.profile;
5513
+ if (requestedProfile !== "default" && this.profile === "default") {
5514
+ cb.log.warn("hermes profile not found; using default profile", { profile: requestedProfile });
5515
+ }
5516
+ this.sessionId = opts.sessionId ?? null;
5517
+ if (this.sessionId) cb.onSession(this.sessionId);
5518
+ if (opts.initialPrompt.trim()) this.enqueue(opts.initialPrompt);
5519
+ }
5520
+ opts;
5521
+ cb;
5522
+ queue = [];
5523
+ turnBusy = false;
5524
+ stopped = false;
5525
+ proc = null;
5526
+ everSucceeded = false;
5527
+ env;
5528
+ profile;
5529
+ sessionId;
5530
+ enqueue(text) {
5531
+ if (this.stopped || !text.trim()) return;
5532
+ this.queue.push(text);
5533
+ this.pump();
5534
+ }
5535
+ pump() {
5536
+ if (this.stopped || this.turnBusy || this.queue.length === 0) return;
5537
+ this.runTurn(this.queue.shift());
5538
+ }
5539
+ runTurn(message) {
5540
+ this.turnBusy = true;
5541
+ this.cb.onActivity("working", `hermes/${this.profile}`);
5542
+ const prompt = buildHermesPrompt(message, this.opts);
5543
+ const args2 = buildHermesArgs(prompt, this.sessionId);
5544
+ const turnFile = path10.join(tmpdir(), `open-tag-hermes-turn-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`);
5545
+ const proc = spawn8("hermes", args2, { cwd: this.opts.cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...this.env, OPEN_TAG_TURN_FILE: turnFile } });
5546
+ this.proc = proc;
5547
+ let stdout = "";
5548
+ const errTail = [];
5549
+ let errLen = 0;
5550
+ proc.stdout?.on("data", (c) => {
5551
+ if (this.stopped) return;
5552
+ if (stdout.length < MAX8) stdout += c.toString();
5553
+ });
5554
+ proc.stderr?.on("data", (c) => {
5555
+ const t = c.toString();
5556
+ errTail.push(t);
5557
+ errLen += t.length;
5558
+ while (errLen > 16384 && errTail.length > 1) errLen -= errTail.shift().length;
5559
+ });
5560
+ proc.on("error", (e) => {
5561
+ this.proc = null;
5562
+ this.turnBusy = false;
5563
+ if (this.stopped) return;
5564
+ this.cb.log.error("hermes spawn failed", { detail: String(e?.message ?? e) });
5565
+ this.cb.onActivity("offline", "hermes not found");
5566
+ if (!this.everSucceeded) this.cb.onExit(1);
5567
+ else this.pump();
5568
+ });
5569
+ proc.on("exit", async (code) => {
5570
+ this.proc = null;
5571
+ if (this.stopped) return;
5572
+ const out = stdout.trim();
5573
+ const tail = errTail.join("").trim();
5574
+ if (code === 0) {
5575
+ this.everSucceeded = true;
5576
+ const nextSessionId = parseHermesSessionId(tail);
5577
+ if (nextSessionId && nextSessionId !== this.sessionId) {
5578
+ this.sessionId = nextSessionId;
5579
+ this.cb.onSession(nextSessionId);
5580
+ }
5581
+ const bridged = await this.bridgeFinalResponse(turnFile, out);
5582
+ if (out) this.cb.onTrajectory([{ kind: "text", text: clip8(out) }]);
5583
+ if (bridged !== false) this.cb.onActivity("online", "");
5584
+ this.turnBusy = false;
5585
+ this.pump();
5586
+ return;
5587
+ }
5588
+ if (this.sessionId && isMissingHermesSession(tail)) {
5589
+ this.cb.log.warn("hermes resume session missing; retrying fresh", { sessionId: this.sessionId });
5590
+ this.sessionId = null;
5591
+ this.cb.onSession(null);
5592
+ this.queue.unshift(message);
5593
+ this.turnBusy = false;
5594
+ this.pump();
5595
+ return;
5596
+ }
5597
+ const last = tail.split("\n").filter(Boolean).pop() || `hermes exited ${code ?? "signal"}`;
5598
+ this.cb.onTrajectory([{ kind: "text", text: "[hermes error] " + clip8(tail || last).slice(0, 800) }]);
5599
+ this.cb.onActivity("error", last.slice(0, 200));
5600
+ this.turnBusy = false;
5601
+ if (!this.everSucceeded) {
5602
+ this.cb.onExit(code ?? 1);
5603
+ return;
5604
+ }
5605
+ this.pump();
5606
+ });
5607
+ }
5608
+ async bridgeFinalResponse(turnFile, stdout) {
5609
+ let state = { sent: false, held: false, engaged: false, target: null };
5610
+ try {
5611
+ state = parseHermesTurnEvents(await readFile(turnFile, "utf8"));
5612
+ } catch {
5613
+ } finally {
5614
+ try {
5615
+ await unlink(turnFile);
5616
+ } catch {
5617
+ }
5618
+ }
5619
+ const decision = hermesBridgeDecision(stdout, state);
5620
+ if (!decision.ok) {
5621
+ if (stdout.trim() && decision.reason !== "already-sent") {
5622
+ this.cb.log.warn("hermes final response not bridged", { reason: decision.reason });
5623
+ this.cb.onActivity("error", `hermes reply not sent (${decision.reason})`);
5624
+ return false;
5625
+ }
5626
+ return null;
5627
+ }
5628
+ try {
5629
+ const result = await postHermesBridgeMessage(fetch, this.env.OPEN_TAG_SERVER_URL ?? "", {
5630
+ authorization: `Bearer ${this.env.OPEN_TAG_AGENT_TOKEN ?? ""}`,
5631
+ "x-agent-id": this.env.OPEN_TAG_AGENT_ID ?? "",
5632
+ "content-type": "application/json"
5633
+ }, decision.target, decision.content);
5634
+ if (!result.ok) {
5635
+ if (result.held) {
5636
+ this.cb.log.warn("hermes final response freshness-held; draft saved for review", { target: decision.target });
5637
+ if (result.text) this.cb.onTrajectory([{ kind: "status", text: "[open-tag freshness hold]\n" + clip8(result.text) }]);
5638
+ this.cb.onActivity("error", "hermes reply held for freshness review");
5639
+ return false;
5640
+ }
5641
+ this.cb.log.warn("hermes final response bridge failed", { status: result.status, target: decision.target });
5642
+ this.cb.onActivity("error", "hermes bridge send failed");
5643
+ return false;
5644
+ } else {
5645
+ this.cb.log.info("hermes final response bridged", { target: decision.target, chars: decision.content.length, held: !!result.held });
5646
+ return true;
5647
+ }
5648
+ } catch (e) {
5649
+ this.cb.log.warn("hermes final response bridge failed", { detail: String(e?.message ?? e), target: decision.target });
5650
+ this.cb.onActivity("error", "hermes bridge send failed");
5651
+ return false;
5652
+ }
5653
+ }
5654
+ stop() {
5655
+ this.stopped = true;
5656
+ const p = this.proc;
5657
+ this.proc = null;
5658
+ if (p) {
5659
+ try {
5660
+ p.kill("SIGTERM");
5661
+ } catch {
5662
+ }
5663
+ }
5664
+ }
5665
+ };
5666
+ var hermesRuntime = {
5667
+ name: "hermes",
5668
+ experimental: true,
5669
+ oneShotWake: true,
5670
+ start(opts, cb) {
5671
+ const run = new HermesRun(opts, cb);
5672
+ return { deliver: (text) => run.enqueue(text), stop: () => run.stop() };
5673
+ }
5674
+ };
5675
+
5379
5676
  // src/daemon/runtimes.ts
5380
5677
  function has(tool) {
5381
5678
  try {
@@ -5386,9 +5683,9 @@ function has(tool) {
5386
5683
  }
5387
5684
  }
5388
5685
  function detectRuntimes() {
5389
- return ["claude", "codex", "copilot", "kimi", "opencode", "pi", "cursor-agent"].filter(has).map((t) => t === "cursor-agent" ? "cursor" : t);
5686
+ return ["claude", "codex", "copilot", "kimi", "opencode", "pi", "cursor-agent", "hermes"].filter(has).map((t) => t === "cursor-agent" ? "cursor" : t);
5390
5687
  }
5391
- var REG = { claude: claudeRuntime, codex: codexRuntime, copilot: copilotRuntime, opencode: opencodeRuntime, kimi: kimiRuntime, pi: piRuntime, cursor: cursorRuntime };
5688
+ var REG = { claude: claudeRuntime, codex: codexRuntime, copilot: copilotRuntime, opencode: opencodeRuntime, kimi: kimiRuntime, pi: piRuntime, cursor: cursorRuntime, hermes: hermesRuntime };
5392
5689
  function getRuntime(name) {
5393
5690
  return REG[name] ?? null;
5394
5691
  }
@@ -5397,14 +5694,28 @@ function getRuntime(name) {
5397
5694
  var DATA_DIR = agentsDir();
5398
5695
  var IDLE_MS = Number(process.env.OPEN_TAG_IDLE_MS ?? 10 * 60 * 1e3);
5399
5696
  var DELIVER_DEBOUNCE_MS = Number(process.env.OPEN_TAG_DELIVER_DEBOUNCE_MS ?? 3e3);
5697
+ var ONE_SHOT_DELIVER_DEBOUNCE_MS = Number(process.env.OPEN_TAG_ONE_SHOT_DELIVER_DEBOUNCE_MS ?? process.env.OPEN_TAG_HERMES_DELIVER_DEBOUNCE_MS ?? 500);
5698
+ var PENDING_DELIVER_TTL_MS = Number(process.env.OPEN_TAG_PENDING_DELIVER_TTL_MS ?? 15e3);
5400
5699
  var AgentManager = class {
5401
- constructor(send) {
5700
+ constructor(send, opts = {}) {
5402
5701
  this.send = send;
5403
- this.binDir = ensureOpenTagBin();
5702
+ this.binDir = opts.binDir ?? ensureOpenTagBin();
5703
+ this.dataDir = opts.dataDir ?? DATA_DIR;
5704
+ this.deliverDebounceMs = opts.deliverDebounceMs ?? DELIVER_DEBOUNCE_MS;
5705
+ this.oneShotDeliverDebounceMs = opts.oneShotDeliverDebounceMs ?? ONE_SHOT_DELIVER_DEBOUNCE_MS;
5706
+ this.pendingDeliverTtlMs = opts.pendingDeliverTtlMs ?? PENDING_DELIVER_TTL_MS;
5707
+ this.runtimeResolver = opts.runtimeResolver ?? getRuntime;
5404
5708
  }
5405
5709
  send;
5406
5710
  agents = /* @__PURE__ */ new Map();
5711
+ starting = /* @__PURE__ */ new Map();
5712
+ pendingDelivers = /* @__PURE__ */ new Map();
5407
5713
  binDir;
5714
+ dataDir;
5715
+ deliverDebounceMs;
5716
+ oneShotDeliverDebounceMs;
5717
+ pendingDeliverTtlMs;
5718
+ runtimeResolver;
5408
5719
  log = createLogger("daemon:agents");
5409
5720
  running() {
5410
5721
  return [...this.agents.keys()];
@@ -5414,6 +5725,7 @@ var AgentManager = class {
5414
5725
  }
5415
5726
  // Tear down process: clear timers + remove from map first (critical: deletion before session.stop() lets the onExit has() guard recognize this as an intentional stop, suppressing unexpected sleeping status) + stop runtime. Returns whether the agent was found.
5416
5727
  teardown(agentId) {
5728
+ this.clearPendingDeliver(agentId);
5417
5729
  const r = this.agents.get(agentId);
5418
5730
  if (!r) return false;
5419
5731
  if (r.idleTimer) clearTimeout(r.idleTimer);
@@ -5439,7 +5751,7 @@ var AgentManager = class {
5439
5751
  async reset(agentId, wipeWorkspace = false, clearMemory = false) {
5440
5752
  this.teardown(agentId);
5441
5753
  this.send({ type: "agent:session", agentId, sessionId: null });
5442
- const dir = path10.join(DATA_DIR, agentId);
5754
+ const dir = path11.join(this.dataDir, agentId);
5443
5755
  if (wipeWorkspace) {
5444
5756
  try {
5445
5757
  await rm(dir, { recursive: true, force: true });
@@ -5449,7 +5761,7 @@ var AgentManager = class {
5449
5761
  }
5450
5762
  } else if (clearMemory) {
5451
5763
  try {
5452
- await writeFile(path10.join(dir, "MEMORY.md"), "# Memory\n\n(reset)\n");
5764
+ await writeFile(path11.join(dir, "MEMORY.md"), "# Memory\n\n(reset)\n");
5453
5765
  this.log.info("memory cleared", { agentId });
5454
5766
  } catch (e) {
5455
5767
  this.log.warn("clearMemory failed", { agentId, detail: String(e) });
@@ -5463,10 +5775,10 @@ var AgentManager = class {
5463
5775
  * title + `## Role`, preserving the agent's own sections. No-op if the workspace/file doesn't exist
5464
5776
  * yet (a not-yet-started agent gets fresh values from the DB when start() seeds it). */
5465
5777
  async syncProfile(agentId, displayName, description) {
5466
- const mem = path10.join(DATA_DIR, agentId, "MEMORY.md");
5778
+ const mem = path11.join(this.dataDir, agentId, "MEMORY.md");
5467
5779
  let content;
5468
5780
  try {
5469
- content = await readFile(mem, "utf8");
5781
+ content = await readFile2(mem, "utf8");
5470
5782
  } catch {
5471
5783
  this.log.debug("syncProfile: no MEMORY.md yet", { agentId });
5472
5784
  return;
@@ -5498,16 +5810,24 @@ var AgentManager = class {
5498
5810
  }
5499
5811
  async start(agentId, config) {
5500
5812
  if (this.agents.has(agentId)) return;
5501
- const runtime = getRuntime(config.runtime ?? "claude");
5813
+ const existing = this.starting.get(agentId);
5814
+ if (existing) return existing;
5815
+ const pending = this.startNow(agentId, config).finally(() => this.starting.delete(agentId));
5816
+ this.starting.set(agentId, pending);
5817
+ return pending;
5818
+ }
5819
+ async startNow(agentId, config) {
5820
+ if (this.agents.has(agentId)) return;
5821
+ const runtime = this.runtimeResolver(config.runtime ?? "claude");
5502
5822
  if (!runtime) {
5503
5823
  this.log.error("no runtime", { runtime: config.runtime });
5504
5824
  this.send({ type: "agent:activity", agentId, activity: "offline", detail: `no runtime: ${config.runtime}` });
5505
5825
  return;
5506
5826
  }
5507
5827
  if (runtime.experimental) this.log.warn("experimental runtime", { runtime: runtime.name });
5508
- const dir = path10.join(DATA_DIR, agentId);
5509
- await mkdir(path10.join(dir, "notes"), { recursive: true });
5510
- const mem = path10.join(dir, "MEMORY.md");
5828
+ const dir = path11.join(this.dataDir, agentId);
5829
+ await mkdir(path11.join(dir, "notes"), { recursive: true });
5830
+ const mem = path11.join(dir, "MEMORY.md");
5511
5831
  try {
5512
5832
  await access(mem);
5513
5833
  } catch {
@@ -5554,6 +5874,8 @@ var AgentManager = class {
5554
5874
  },
5555
5875
  log: this.log
5556
5876
  };
5877
+ const pendingDeliveryCount = this.pendingDelivers.get(agentId)?.items.length ?? 0;
5878
+ const useOneShotWakeNudge = !!runtime.oneShotWake && pendingDeliveryCount > 0;
5557
5879
  this.agents.set(agentId, running);
5558
5880
  running.session = runtime.start({
5559
5881
  cwd: dir,
@@ -5562,18 +5884,55 @@ var AgentManager = class {
5562
5884
  sessionId: config.sessionId,
5563
5885
  systemPrompt,
5564
5886
  env,
5565
- initialPrompt: config.sessionId ? RESUME_NUDGE : STARTUP_NUDGE
5887
+ initialPrompt: useOneShotWakeNudge ? ONE_SHOT_WAKE_NUDGE : config.sessionId ? RESUME_NUDGE : STARTUP_NUDGE
5566
5888
  }, cb);
5567
5889
  this.send({ type: "agent:status", agentId, status: "active" });
5568
5890
  this.send({ type: "agent:activity", agentId, activity: "working", detail: "starting" });
5569
5891
  this.log.info("agent started", { agentId, runtime: runtime.name, model: config.model ?? "(default)", resume: !!config.sessionId, experimental: runtime.experimental ?? false });
5570
5892
  this.resetIdle(agentId);
5893
+ if (useOneShotWakeNudge) {
5894
+ this.clearPendingDeliver(agentId);
5895
+ this.log.debug("pending deliver consumed by one-shot wake nudge", { agentId, runtime: runtime.name, count: pendingDeliveryCount });
5896
+ } else {
5897
+ this.flushPendingDeliver(agentId);
5898
+ }
5571
5899
  }
5572
- /** server agent:deliver — wake a running agent with new messages; agents not yet running will self-check on startup once agent:start arrives. */
5900
+ queuePendingDeliver(agentId, item) {
5901
+ let q = this.pendingDelivers.get(agentId);
5902
+ if (!q) {
5903
+ const timer = setTimeout(() => {
5904
+ this.pendingDelivers.delete(agentId);
5905
+ this.log.debug("pending deliver expired", { agentId });
5906
+ }, this.pendingDeliverTtlMs);
5907
+ q = { items: [], timer };
5908
+ this.pendingDelivers.set(agentId, q);
5909
+ }
5910
+ q.items.push(item);
5911
+ if (q.items.length > 10) q.items.shift();
5912
+ this.log.debug("deliver queued pending start", { agentId, count: q.items.length });
5913
+ }
5914
+ clearPendingDeliver(agentId) {
5915
+ const q = this.pendingDelivers.get(agentId);
5916
+ if (!q) return;
5917
+ clearTimeout(q.timer);
5918
+ this.pendingDelivers.delete(agentId);
5919
+ }
5920
+ flushPendingDeliver(agentId) {
5921
+ const q = this.pendingDelivers.get(agentId);
5922
+ if (!q) return;
5923
+ this.clearPendingDeliver(agentId);
5924
+ this.log.debug("pending deliver -> agent", { agentId, count: q.items.length });
5925
+ for (const item of q.items) this.deliver(agentId, item.from, item.target, item.mentioned, item.meta);
5926
+ }
5927
+ debounceMsFor(r) {
5928
+ const runtime = this.runtimeResolver(r.config.runtime ?? "claude");
5929
+ return runtime?.oneShotWake ? this.oneShotDeliverDebounceMs : this.deliverDebounceMs;
5930
+ }
5931
+ /** server agent:deliver — wake a running agent with new messages; if start is still preparing the workspace, briefly queue and flush once the runtime exists. */
5573
5932
  deliver(agentId, from, target, mentioned = false, meta = {}) {
5574
5933
  const r = this.agents.get(agentId);
5575
5934
  if (!r) {
5576
- this.log.debug("deliver: agent not running yet", { agentId });
5935
+ this.queuePendingDeliver(agentId, { from, target, mentioned, meta });
5577
5936
  return;
5578
5937
  }
5579
5938
  const tname = meta.targetName ?? target;
@@ -5601,29 +5960,29 @@ var AgentManager = class {
5601
5960
  } catch (e) {
5602
5961
  this.log.warn("deliver failed", { agentId, detail: String(e) });
5603
5962
  }
5604
- }, DELIVER_DEBOUNCE_MS);
5963
+ }, this.debounceMsFor(r));
5605
5964
  r.deliverBuf = buf;
5606
5965
  }
5607
5966
  };
5608
5967
 
5609
5968
  // src/daemon/workspace.ts
5610
- import { readdir, readFile as readFile2, stat } from "node:fs/promises";
5611
- import path11 from "node:path";
5969
+ import { readdir, readFile as readFile3, stat } from "node:fs/promises";
5970
+ import path12 from "node:path";
5612
5971
  import os3 from "node:os";
5613
5972
  var DATA_DIR2 = agentsDir();
5614
5973
  var MAX_FILE = 256 * 1024;
5615
5974
  var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git"]);
5616
5975
  function safe(agentId, rel) {
5617
- const root = path11.join(DATA_DIR2, agentId);
5618
- const target = path11.resolve(root, rel || ".");
5619
- if (target !== root && !target.startsWith(root + path11.sep)) return null;
5976
+ const root = path12.join(DATA_DIR2, agentId);
5977
+ const target = path12.resolve(root, rel || ".");
5978
+ if (target !== root && !target.startsWith(root + path12.sep)) return null;
5620
5979
  return target;
5621
5980
  }
5622
5981
  async function walk(root, rel, acc, depth) {
5623
5982
  if (depth > 6 || acc.length > 2e3) return;
5624
5983
  let ds;
5625
5984
  try {
5626
- ds = await readdir(path11.join(root, rel), { withFileTypes: true });
5985
+ ds = await readdir(path12.join(root, rel), { withFileTypes: true });
5627
5986
  } catch {
5628
5987
  return;
5629
5988
  }
@@ -5633,7 +5992,7 @@ async function walk(root, rel, acc, depth) {
5633
5992
  let size = 0;
5634
5993
  let modifiedAt = null;
5635
5994
  try {
5636
- const s = await stat(path11.join(root, childRel));
5995
+ const s = await stat(path12.join(root, childRel));
5637
5996
  size = d.isFile() ? s.size : 0;
5638
5997
  modifiedAt = s.mtime.toISOString();
5639
5998
  } catch {
@@ -5643,13 +6002,13 @@ async function walk(root, rel, acc, depth) {
5643
6002
  }
5644
6003
  }
5645
6004
  async function listWorkspace(agentId, _subPath = "") {
5646
- const root = path11.join(DATA_DIR2, agentId);
6005
+ const root = path12.join(DATA_DIR2, agentId);
5647
6006
  try {
5648
6007
  const files = [];
5649
6008
  await walk(root, "", files, 0);
5650
- return { files };
6009
+ return { files, root };
5651
6010
  } catch (e) {
5652
- return { error: String(e?.message ?? e) };
6011
+ return { error: String(e?.message ?? e), root };
5653
6012
  }
5654
6013
  }
5655
6014
  function fmField(fm, key) {
@@ -5681,7 +6040,7 @@ async function readSkillsDir(dir, sourcePath) {
5681
6040
  if (!e.isDirectory()) continue;
5682
6041
  let name = e.name, description = "", userInvocable = false;
5683
6042
  try {
5684
- const txt = await readFile2(path11.join(dir, e.name, "SKILL.md"), "utf8");
6043
+ const txt = await readFile3(path12.join(dir, e.name, "SKILL.md"), "utf8");
5685
6044
  const fm = /^---\n([\s\S]*?)\n---/.exec(txt);
5686
6045
  if (fm) {
5687
6046
  name = fmField(fm[1], "name") || e.name;
@@ -5696,21 +6055,21 @@ async function readSkillsDir(dir, sourcePath) {
5696
6055
  return out;
5697
6056
  }
5698
6057
  var HOME = os3.homedir();
5699
- var UNIVERSAL_SKILLS = { dir: path11.join(HOME, ".agents", "skills"), label: "~/.agents/skills" };
6058
+ var UNIVERSAL_SKILLS = { dir: path12.join(HOME, ".agents", "skills"), label: "~/.agents/skills" };
5700
6059
  var PROVIDER_HOME_SKILLS = {
5701
- claude: { dir: path11.join(HOME, ".claude", "skills"), label: "~/.claude/skills" },
5702
- codex: { dir: path11.join(process.env.CODEX_HOME || path11.join(HOME, ".codex"), "skills"), label: "~/.codex/skills" },
5703
- copilot: { dir: path11.join(HOME, ".copilot", "skills"), label: "~/.copilot/skills" },
5704
- opencode: { dir: path11.join(HOME, ".config", "opencode", "skills"), label: "~/.config/opencode/skills" },
5705
- cursor: { dir: path11.join(HOME, ".cursor", "skills"), label: "~/.cursor/skills" },
5706
- pi: { dir: path11.join(HOME, ".pi", "agent", "skills"), label: "~/.pi/agent/skills" }
6060
+ claude: { dir: path12.join(HOME, ".claude", "skills"), label: "~/.claude/skills" },
6061
+ codex: { dir: path12.join(process.env.CODEX_HOME || path12.join(HOME, ".codex"), "skills"), label: "~/.codex/skills" },
6062
+ copilot: { dir: path12.join(HOME, ".copilot", "skills"), label: "~/.copilot/skills" },
6063
+ opencode: { dir: path12.join(HOME, ".config", "opencode", "skills"), label: "~/.config/opencode/skills" },
6064
+ cursor: { dir: path12.join(HOME, ".cursor", "skills"), label: "~/.cursor/skills" },
6065
+ pi: { dir: path12.join(HOME, ".pi", "agent", "skills"), label: "~/.pi/agent/skills" }
5707
6066
  };
5708
6067
  var PROVIDER_WS_DIR = { claude: ".claude", codex: ".codex", copilot: ".copilot", opencode: ".opencode", cursor: ".cursor", pi: ".pi" };
5709
6068
  function skillRootsFor(runtime, agentId) {
5710
6069
  const home = PROVIDER_HOME_SKILLS[runtime];
5711
6070
  const global = home ? [home, UNIVERSAL_SKILLS] : [UNIVERSAL_SKILLS];
5712
6071
  const wsName = PROVIDER_WS_DIR[runtime];
5713
- const workspace = wsName ? { dir: path11.join(DATA_DIR2, agentId, wsName, "skills"), label: `<workspace>/${wsName}/skills` } : null;
6072
+ const workspace = wsName ? { dir: path12.join(DATA_DIR2, agentId, wsName, "skills"), label: `<workspace>/${wsName}/skills` } : null;
5714
6073
  return { global, workspace };
5715
6074
  }
5716
6075
  async function listSkills(agentId, runtime = "claude") {
@@ -5726,7 +6085,7 @@ async function readWorkspaceFile(agentId, rel) {
5726
6085
  const s = await stat(file);
5727
6086
  if (!s.isFile()) return { error: "not a file" };
5728
6087
  if (s.size > MAX_FILE) return { error: `file too large (${s.size} bytes, max ${MAX_FILE})` };
5729
- const buf = await readFile2(file);
6088
+ const buf = await readFile3(file);
5730
6089
  if (buf.includes(0)) return { error: "binary file" };
5731
6090
  return { path: rel, content: buf.toString("utf8") };
5732
6091
  } catch (e) {
@@ -5735,7 +6094,10 @@ async function readWorkspaceFile(agentId, rel) {
5735
6094
  }
5736
6095
 
5737
6096
  // src/daemon/listModels.ts
5738
- import { spawn as spawn8 } from "node:child_process";
6097
+ import { spawn as spawn9 } from "node:child_process";
6098
+ import { existsSync as existsSync2, readdirSync, readFileSync, statSync } from "node:fs";
6099
+ import { homedir as homedir2 } from "node:os";
6100
+ import path13 from "node:path";
5739
6101
  var titleCase = (s) => s ? s[0].toUpperCase() + s.slice(1) : s;
5740
6102
  function isModelId(s) {
5741
6103
  return /^[A-Za-z][A-Za-z0-9\-_./]*$/.test(s);
@@ -5842,6 +6204,71 @@ function isPiNoise(line) {
5842
6204
  const l = line.toLowerCase();
5843
6205
  return l.includes("no models match pattern") || l.startsWith("warning:") || l.startsWith("error:") || l.startsWith("info:");
5844
6206
  }
6207
+ function labelFromId(id) {
6208
+ return id.split(/[-_]/).filter(Boolean).map(titleCase).join(" ") || id;
6209
+ }
6210
+ function firstYamlString(text, keys) {
6211
+ for (const key of keys) {
6212
+ const re = new RegExp(`^${key}:\\s*["']?([^"'\\n#]+)`, "m");
6213
+ const m = re.exec(text);
6214
+ if (m?.[1]?.trim()) return m[1].trim();
6215
+ }
6216
+ return null;
6217
+ }
6218
+ function hermesProfileLabel(dir, id) {
6219
+ for (const filename of ["profile.yaml", "config.yaml"]) {
6220
+ const file = path13.join(dir, filename);
6221
+ if (!existsSync2(file)) continue;
6222
+ try {
6223
+ const text = readFileSync(file, "utf8").slice(0, 4096);
6224
+ const label = firstYamlString(text, ["display_name", "displayName", "name", "title"]);
6225
+ if (label) return label;
6226
+ } catch {
6227
+ }
6228
+ }
6229
+ return labelFromId(id);
6230
+ }
6231
+ function isHermesProfileDir(dir) {
6232
+ return ["profile.yaml", "SOUL.md", "config.yaml"].some((name) => existsSync2(path13.join(dir, name)));
6233
+ }
6234
+ function discoverHermesProfilesFromRoots(roots) {
6235
+ const found = /* @__PURE__ */ new Map([
6236
+ ["default", { id: "default", label: "Default profile", provider: "hermes", default: true }]
6237
+ ]);
6238
+ for (const root of roots) {
6239
+ if (!root || !existsSync2(root)) continue;
6240
+ let entries;
6241
+ try {
6242
+ entries = readdirSync(root);
6243
+ } catch {
6244
+ continue;
6245
+ }
6246
+ for (const entry of entries) {
6247
+ const dir = path13.join(root, entry);
6248
+ try {
6249
+ if (!statSync(dir).isDirectory()) continue;
6250
+ } catch {
6251
+ continue;
6252
+ }
6253
+ if (!isHermesProfileDir(dir)) continue;
6254
+ if (!isModelId(entry)) continue;
6255
+ if (!found.has(entry)) found.set(entry, { id: entry, label: hermesProfileLabel(dir, entry), provider: "hermes" });
6256
+ }
6257
+ }
6258
+ return [...found.values()].sort((a, b) => {
6259
+ if (a.id === "default") return -1;
6260
+ if (b.id === "default") return 1;
6261
+ return a.id.localeCompare(b.id);
6262
+ });
6263
+ }
6264
+ function discoverHermesProfiles() {
6265
+ const home = homedir2();
6266
+ const roots = [
6267
+ process.env.HERMES_PROFILE_DIR,
6268
+ path13.join(home, ".hermes", "profiles")
6269
+ ].filter((v) => !!v);
6270
+ return discoverHermesProfilesFromRoots(roots);
6271
+ }
5845
6272
  var LIST_TIMEOUT_MS = 7e3;
5846
6273
  var OUT_CAP = 256 * 1024;
5847
6274
  function runList(bin, args2, timeoutMs = LIST_TIMEOUT_MS) {
@@ -5850,7 +6277,7 @@ function runList(bin, args2, timeoutMs = LIST_TIMEOUT_MS) {
5850
6277
  delete env.NODE_OPTIONS;
5851
6278
  let proc;
5852
6279
  try {
5853
- proc = spawn8(bin, args2, { stdio: ["ignore", "pipe", "pipe"], env });
6280
+ proc = spawn9(bin, args2, { stdio: ["ignore", "pipe", "pipe"], env });
5854
6281
  } catch (e) {
5855
6282
  return resolve({ stdout: "", stderr: String(e?.message ?? e), code: 1 });
5856
6283
  }
@@ -5913,6 +6340,10 @@ async function listModels(runtime) {
5913
6340
  const models = parseCodexModels(r.stdout);
5914
6341
  return models.length ? models : null;
5915
6342
  }
6343
+ case "hermes": {
6344
+ const profiles = discoverHermesProfiles();
6345
+ return profiles.length ? profiles : null;
6346
+ }
5916
6347
  default:
5917
6348
  return null;
5918
6349
  }
@@ -5928,8 +6359,10 @@ for (let i = 0; i < args.length; i++) {
5928
6359
  if (args[i] === "--api-key" && args[i + 1]) apiKey = args[++i];
5929
6360
  }
5930
6361
  if (!serverUrl) serverUrl = `http://localhost:${process.env.PORT ?? 7777}`;
6362
+ if (!apiKey) apiKey = process.env.OPEN_TAG_DAEMON_API_KEY ?? "";
5931
6363
  if (!apiKey) {
5932
6364
  console.error("Usage: open-tag-daemon [--server-url <url>] --api-key <machineKey>");
6365
+ console.error(" or: OPEN_TAG_DAEMON_API_KEY=<machineKey> open-tag-daemon [--server-url <url>]");
5933
6366
  process.exit(1);
5934
6367
  }
5935
6368
  var MID_FILE = machineIdFile();
@@ -5942,7 +6375,7 @@ var readMachineId = () => {
5942
6375
  };
5943
6376
  var saveMachineId = (id) => {
5944
6377
  try {
5945
- fs3.mkdirSync(path12.dirname(MID_FILE), { recursive: true });
6378
+ fs3.mkdirSync(path14.dirname(MID_FILE), { recursive: true });
5946
6379
  fs3.writeFileSync(MID_FILE, id);
5947
6380
  } catch {
5948
6381
  }
@@ -6003,7 +6436,7 @@ conn = new Connection(serverUrl, apiKey, (msg) => {
6003
6436
  runningAgents: mgr.running(),
6004
6437
  hostname: os4.hostname(),
6005
6438
  os: `${os4.platform()} ${os4.arch()}`,
6006
- daemonVersion: "0.7.1",
6439
+ daemonVersion: "0.8.0",
6007
6440
  machineId: readMachineId()
6008
6441
  // Stable identity: empty on first connection; server sends it back via ready:ack for persistence.
6009
6442
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fancyboi999/open-tag-daemon",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "open-tag compute-plane daemon — connect any machine to an open-tag server so its agents run there. No repo clone needed.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",