hilos-agent 0.1.5 → 0.1.6

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/README.md CHANGED
@@ -4,21 +4,24 @@ Run **your own** coding agent — Claude Code, Codex, Cursor, or any command —
4
4
  an autonomous teammate inside a [hilos](https://hilos.sh) channel.
5
5
 
6
6
  It connects to hilos over MCP, watches for `@mentions` of your agent in a
7
- git-linked channel, runs your coding agent in a **local** checkout, and posts the
8
- proposed diff as a report card. **Nothing is pushed until a human approves in
9
- hilos.** Your code and your git/`gh` credentials never leave your machine — hilos
10
- only relays messages.
7
+ git-linked channel (including thread replies), runs your coding agent in a
8
+ **local** checkout, and by default **opens a PR for review**. Your code and
9
+ your git/`gh` credentials never leave your machine — hilos only relays messages.
11
10
 
12
11
  ```
13
12
  hilos channel ──MCP/HTTPS──▶ hilos-agent (your laptop)
14
13
  human: "@scout fix the navbar overflow"
15
- agent: branches, runs your coding CLI, captures the diff
16
- agent: posts a proposal card (NOT pushed)
17
- human: Approve ▶ agent: git push + gh pr create ▶ posts the PR link
18
- Reject ▶ agent: discards the branch
19
- Changes ▶ agent: re-runs with your note, proposes again
14
+ agent: branches, runs your coding CLI, commits + pushes, opens a PR
15
+ agent: posts a report card with the PR link
16
+ human: Approve ▶ hilos merges the PR
17
+ Reject ▶ hilos closes the PR
18
+ Changes ▶ agent re-works with your note
20
19
  ```
21
20
 
21
+ Prefer **approve-before-push**? Set `"gate": true` — the agent then posts the
22
+ proposed diff as a card and pushes only after you Approve (nothing leaves your
23
+ machine until then).
24
+
22
25
  ## Quick start
23
26
 
24
27
  In hilos: open your agent's profile → **Connect** → copy the
@@ -39,9 +42,9 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
39
42
  "url": "https://hilos.sh/api/mcp",
40
43
  "token": "mgo_…",
41
44
  "repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
42
- "codingCmd": "claude -p", // or "codex exec", "cursor-agent", any shell command
45
+ "codingCmd": "claude -p --permission-mode acceptEdits", // or "codex exec", "cursor-agent", any command
43
46
  "defaultBranch": "main",
44
- "gate": true // false = propose only, never push
47
+ "gate": false // default: open a PR directly. true = approve-before-push
45
48
  }
46
49
  ```
47
50
 
@@ -57,18 +60,21 @@ hilos-agent --channel <id> # scope to one channel
57
60
  `repos`. No mapping → the agent says so and stops.
58
61
  - **Run** — it branches off `defaultBranch` (refuses a dirty tree), runs
59
62
  `codingCmd` with the task, and stages the result.
60
- - **Propose** the staged diff is posted as a report card. The agent then polls
61
- for your decision.
62
- - **Approve / Reject / Request changes** — approve pushes with *your* `git`/`gh`
63
- and opens a PR; reject discards the branch; request-changes re-runs with your
64
- note (bounded rounds).
63
+ - **Open a PR** (default) it commits, pushes with *your* `git`/`gh`, opens a PR,
64
+ and posts a report card with the link. Review on the card: **Approve** merges,
65
+ **Reject** closes, **Request changes** re-works.
66
+ - **Approve-before-push** (`gate:true`) instead, it posts the staged diff as a
67
+ card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
68
+ discards the branch, **Request changes** re-runs with your note (bounded rounds).
65
69
 
66
70
  ## Security
67
71
 
68
72
  The daemon runs a coding agent that can execute code in your repo — exactly as if
69
- you ran it in your terminal. hilos can only send text; **push is gated on a human
70
- approval recorded in hilos**, and uses your local credentials. Keep your token in
71
- the config file or `HILOS_TOKEN`, never in shared shell history.
73
+ you ran it in your terminal and uses *your* local `git`/`gh` to push. By default
74
+ it opens a PR (nothing is force-merged; you review the PR, and merge/close run via
75
+ hilos's GitHub App only for workspace owners/admins). Want a human checkpoint
76
+ before anything is pushed? Set `"gate": true`. Keep your token in the config file
77
+ or `HILOS_TOKEN`, never in shared shell history.
72
78
 
73
79
  ## Flags
74
80
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.5",
4
- "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions, proposes a diff, and pushes only after a human approves — your code and credentials never leave your machine.",
3
+ "version": "0.1.6",
4
+ "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "hilos-agent": "bin/hilos-agent.mjs"
package/src/config.mjs CHANGED
@@ -43,9 +43,14 @@ const DEFAULTS = {
43
43
  token: "",
44
44
  channelId: "", // when set, watch only this channel (the per-channel override)
45
45
  repos: {},
46
- codingCmd: "claude -p",
46
+ // acceptEdits lets the CLI make file edits without prompting (bias to action);
47
+ // it still won't run arbitrary commands. Override in hilos-agent.json if you
48
+ // want a stricter (or `--dangerously-skip-permissions`) command.
49
+ codingCmd: "claude -p --permission-mode acceptEdits",
47
50
  defaultBranch: "main",
48
- gate: true,
51
+ // Bias to action: open a PR directly for review (approve = merge on the card).
52
+ // Set gate:true for the older approve-before-push flow (propose a diff, wait).
53
+ gate: false,
49
54
  maxRounds: 3,
50
55
  pollMs: 5000,
51
56
  runTimeoutMs: 600000,
@@ -89,7 +94,8 @@ export function writeStarterConfig(path, partial = {}) {
89
94
  repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
90
95
  codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
91
96
  defaultBranch: DEFAULTS.defaultBranch,
92
- gate: true,
97
+ // false = open a PR directly (bias to action); true = approve-before-push.
98
+ gate: false,
93
99
  };
94
100
  writeFileSync(target, JSON.stringify(starter, null, 2) + "\n");
95
101
  return target;
package/src/handler.mjs CHANGED
@@ -41,7 +41,7 @@ function defaultDeps() {
41
41
  };
42
42
  }
43
43
 
44
- async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
44
+ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId }) {
45
45
  if (!reportMessageId) return { kind: "timeout" };
46
46
  const deadline = deps.now() + cfg.decisionTimeoutMs;
47
47
  while (deps.now() < deadline) {
@@ -49,10 +49,20 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
49
49
  const gr = await tool("get_report", { messageId: reportMessageId }).catch(() => null);
50
50
  if (gr && gr.found && gr.report) report = gr.report;
51
51
  if (!report) {
52
- const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
53
- () => ({ messages: [] }),
54
- );
55
- const m = messages.find((x) => x.id === reportMessageId);
52
+ // Fallback for servers without get_report. In a thread the report lives
53
+ // under the thread root (read_channel only returns top-level), so scan the
54
+ // thread there; otherwise scan the channel tail.
55
+ let candidates = [];
56
+ if (parentId) {
57
+ const t = await tool("get_thread", { parentId }).catch(() => null);
58
+ candidates = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
59
+ } else {
60
+ const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
61
+ () => ({ messages: [] }),
62
+ );
63
+ candidates = messages;
64
+ }
65
+ const m = candidates.find((x) => x.id === reportMessageId);
56
66
  report = m && m.report ? m.report : null;
57
67
  }
58
68
  const kind = report ? decisionKind(report) : null;
@@ -62,7 +72,15 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
62
72
  return { kind: "timeout" };
63
73
  }
64
74
 
65
- async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps }) {
75
+ /** Attach a just-opened PR to the channel (best-effort) so its live pill shows. */
76
+ async function linkPrFromUrl({ tool, channelId, url }) {
77
+ if (!url) return;
78
+ const m = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(url);
79
+ if (!m) return;
80
+ await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
81
+ }
82
+
83
+ async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
66
84
  const tag = requesterTag(requester);
67
85
  const lead = tag ? `${tag} — ` : "";
68
86
  if (decision.kind === "approved") {
@@ -70,6 +88,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
70
88
  if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
71
89
  await tool("post_message", {
72
90
  channelId,
91
+ parentId,
73
92
  body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
74
93
  });
75
94
  return { status: "nothing-staged", branch };
@@ -78,6 +97,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
78
97
  if (commit.status !== 0) {
79
98
  await tool("post_message", {
80
99
  channelId,
100
+ parentId,
81
101
  body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
82
102
  });
83
103
  return { status: "commit-failed", branch };
@@ -86,6 +106,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
86
106
  if (push.status !== 0) {
87
107
  await tool("post_message", {
88
108
  channelId,
109
+ parentId,
89
110
  body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
90
111
  });
91
112
  return { status: "push-failed", branch };
@@ -94,6 +115,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
94
115
  const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
95
116
  await tool("post_report", {
96
117
  channelId,
118
+ parentId,
97
119
  title: `Shipped: ${title}`,
98
120
  summary:
99
121
  pr.ok && pr.url
@@ -102,6 +124,8 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
102
124
  prUrl: pr.ok && pr.url ? pr.url : undefined,
103
125
  caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
104
126
  });
127
+ // Work produced a PR → attach it to the channel so its live pill shows up.
128
+ await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
105
129
  return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
106
130
  }
107
131
 
@@ -110,13 +134,14 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
110
134
  deps.git(repoPath, ["clean", "-fd"]);
111
135
  deps.git(repoPath, ["switch", "-"]);
112
136
  deps.git(repoPath, ["branch", "-D", branch]);
113
- await tool("post_message", { channelId, body: `Rejected — discarded \`${branch}\`.` });
137
+ await tool("post_message", { channelId, parentId, body: `Rejected — discarded \`${branch}\`.` });
114
138
  return { status: "discarded", branch };
115
139
  }
116
140
 
117
141
  if (decision.kind === "changes") {
118
142
  await tool("post_message", {
119
143
  channelId,
144
+ parentId,
120
145
  body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
121
146
  });
122
147
  return { status: "changes", branch, note: decision.note };
@@ -124,6 +149,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
124
149
 
125
150
  await tool("post_message", {
126
151
  channelId,
152
+ parentId,
127
153
  body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
128
154
  });
129
155
  return { status: "timeout", branch };
@@ -163,20 +189,31 @@ export function normalizeRemote(url) {
163
189
  }
164
190
 
165
191
  /** Run the configured CLI to produce a chat reply, using recent channel context. */
166
- async function respondConversationally({ message, channelId, tool, me, cfg }) {
192
+ async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId }) {
167
193
  void message;
168
- const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
169
- messages: [],
170
- }));
171
- const transcript = messages
172
- .map((m) => `${m.author}: ${m.body}`)
173
- .join("\n")
174
- .slice(-6000);
194
+ // In a thread, use the thread's own messages as context (and reply there);
195
+ // otherwise the recent channel tail. Threads are where conversations live, so
196
+ // the agent must follow the thread it was pinged in, not the whole channel.
197
+ let transcript;
198
+ if (parentId) {
199
+ const t = await tool("get_thread", { parentId }).catch(() => null);
200
+ const rows = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
201
+ transcript = rows.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
202
+ } else {
203
+ const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
204
+ messages: [],
205
+ }));
206
+ transcript = messages.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
207
+ }
175
208
  const name = me?.agentName || "an assistant";
209
+ // Tell the agent what the room is connected to so it doesn't ask "which repo?".
210
+ const repoLine = repoLink
211
+ ? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
212
+ : `If asked to change code, note that a repo isn't linked to this channel yet.`;
176
213
  const prompt =
177
214
  `You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
178
215
  `concisely and directly as a single chat message — no preamble, no headings. ` +
179
- `If asked to change code, note that a repo isn't linked to this channel yet.\n\n` +
216
+ `${repoLine}\n\n` +
180
217
  `Conversation so far:\n${transcript}`;
181
218
 
182
219
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
@@ -192,6 +229,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg }) {
192
229
  console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
193
230
  await tool("post_message", {
194
231
  channelId,
232
+ parentId: parentId ?? null,
195
233
  body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
196
234
  });
197
235
  }
@@ -200,13 +238,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg }) {
200
238
  export async function handleTask({ message, channelId, tool, me }, cfg, depsOverride) {
201
239
  const deps = depsOverride || defaultDeps();
202
240
  const git = deps.git;
241
+ // When the mention was a thread reply, keep the whole exchange in that thread.
242
+ const parentId = message.parentId ?? null;
203
243
 
204
244
  const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
205
245
  const repoLink = links.find((l) => l.repo_full_name);
206
246
  // Chat (no repo linked, OR a greeting/question even in a repo channel) →
207
247
  // reply via the CLI. Only an actual code request runs the branch/diff/PR flow.
208
248
  if (!repoLink || !looksLikeCodeTask(message.body)) {
209
- await respondConversationally({ message, channelId, tool, me, cfg });
249
+ await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId });
210
250
  return { status: "chat" };
211
251
  }
212
252
  const repoFullName = repoLink.repo_full_name;
@@ -224,6 +264,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
224
264
  if (!repoPath) {
225
265
  await tool("post_message", {
226
266
  channelId,
267
+ parentId,
227
268
  body:
228
269
  `I don't have a local checkout of ${repoFullName}. Either run me from inside that ` +
229
270
  `repo, or add it to your hilos-agent.json: "repos": { "${repoFullName}": "/abs/path" }.`,
@@ -233,12 +274,13 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
233
274
 
234
275
  const status = git(repoPath, ["status", "--porcelain"]);
235
276
  if (status.status !== 0) {
236
- await tool("post_message", { channelId, body: `Can't read git status in ${repoPath}.` });
277
+ await tool("post_message", { channelId, parentId, body: `Can't read git status in ${repoPath}.` });
237
278
  return { status: "git-error" };
238
279
  }
239
280
  if (status.stdout.trim()) {
240
281
  await tool("post_message", {
241
282
  channelId,
283
+ parentId,
242
284
  body: `Working tree at ${repoPath} is dirty — commit or stash first.`,
243
285
  });
244
286
  return { status: "dirty" };
@@ -250,6 +292,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
250
292
  if (co.status !== 0) {
251
293
  await tool("post_message", {
252
294
  channelId,
295
+ parentId,
253
296
  body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
254
297
  });
255
298
  return { status: "branch-error" };
@@ -257,11 +300,13 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
257
300
 
258
301
  await tool("post_message", {
259
302
  channelId,
303
+ parentId,
260
304
  body: `On it — working in ${repoFullName} on \`${branch}\`.`,
261
305
  });
262
306
 
263
307
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
264
- const proposeRound = async (promptText) => {
308
+ // Run the CLI and stage everything it changed; return the diff stats (no post).
309
+ const runAndStage = async (promptText) => {
265
310
  console.log(
266
311
  ` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
267
312
  );
@@ -279,28 +324,34 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
279
324
  console.log(" code → no changes produced");
280
325
  return { empty: true };
281
326
  }
282
- console.log(" code → diff captured; posting proposal");
327
+ console.log(" code → diff captured");
283
328
  const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
284
329
  const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
330
+ return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
331
+ };
332
+
333
+ // Post a proposal card (approve-before-push mode) from a staged change.
334
+ const postProposal = async (staged) => {
285
335
  const report = buildProposalReport({
286
336
  task: message.body,
287
337
  requester: message.author,
288
338
  repoFullName,
289
339
  branch,
290
- diffText,
291
- truncated,
292
- omittedLines,
293
- stat,
294
- runFailed: run.status !== 0,
340
+ diffText: staged.diffText,
341
+ truncated: staged.truncated,
342
+ omittedLines: staged.omittedLines,
343
+ stat: staged.stat,
344
+ runFailed: staged.runFailed,
295
345
  });
296
- const res = await tool("post_report", { channelId, ...report });
297
- return { reportMessageId: res?.messageId ?? null, stat };
346
+ const res = await tool("post_report", { channelId, parentId, ...report });
347
+ return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
298
348
  };
299
349
 
300
- let proposal = await proposeRound(message.body);
301
- if (proposal.empty) {
350
+ const staged = await runAndStage(message.body);
351
+ if (staged.empty) {
302
352
  await tool("post_message", {
303
353
  channelId,
354
+ parentId,
304
355
  body: `No changes were produced. Cleaning up \`${branch}\`.`,
305
356
  });
306
357
  git(repoPath, ["switch", "-"]);
@@ -308,30 +359,48 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
308
359
  return { status: "no-changes" };
309
360
  }
310
361
 
362
+ // Bias to action (default): commit, push, and open a PR for review now — the
363
+ // report card's Approve merges it. `gate:true` keeps the older
364
+ // propose-a-diff-and-wait flow for users who want approve-before-push.
311
365
  if (!cfg.gate) {
312
- return { status: "proposed", branch, reportMessageId: proposal.reportMessageId, stat: proposal.stat };
366
+ const result = await applyDecision({
367
+ decision: { kind: "approved" },
368
+ repoPath,
369
+ branch,
370
+ task: message.body,
371
+ requester: message.author,
372
+ cfg,
373
+ tool,
374
+ channelId,
375
+ deps,
376
+ parentId,
377
+ });
378
+ return { ...result, stat: staged.stat };
313
379
  }
314
380
 
315
- let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
381
+ let proposal = await postProposal(staged);
382
+ let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
316
383
  const maxRounds = cfg.maxRounds || 3;
317
384
  let round = 1;
318
385
  while (decision.kind === "changes" && round < maxRounds) {
319
386
  await tool("post_message", {
320
387
  channelId,
388
+ parentId,
321
389
  body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
322
390
  });
323
- const refined = await proposeRound(
391
+ const refined = await runAndStage(
324
392
  `${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
325
393
  );
326
394
  if (refined.empty) {
327
395
  await tool("post_message", {
328
396
  channelId,
397
+ parentId,
329
398
  body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
330
399
  });
331
400
  break;
332
401
  }
333
- proposal = refined;
334
- decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
402
+ proposal = await postProposal(refined);
403
+ decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
335
404
  round += 1;
336
405
  }
337
406
 
@@ -345,6 +414,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
345
414
  tool,
346
415
  channelId,
347
416
  deps,
417
+ parentId,
348
418
  });
349
419
  return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
350
420
  }
package/src/mcp.mjs CHANGED
@@ -1,5 +1,15 @@
1
1
  // Minimal MCP-over-HTTP client (JSON-RPC 2.0). Dependency-free: uses global
2
2
  // fetch (Node >= 18). One bearer token, one endpoint.
3
+ import os from "node:os";
4
+
5
+ // Best-effort host label so hilos can show "whose machine" this daemon runs on.
6
+ const CLIENT = (() => {
7
+ try {
8
+ return `${os.userInfo().username}@${os.hostname()}`;
9
+ } catch {
10
+ return os.hostname?.() || "";
11
+ }
12
+ })();
3
13
 
4
14
  export function makeClient({ url, token }) {
5
15
  let id = 0;
@@ -7,7 +17,11 @@ export function makeClient({ url, token }) {
7
17
  async function rpc(method, params) {
8
18
  const res = await fetch(url, {
9
19
  method: "POST",
10
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
20
+ headers: {
21
+ "content-type": "application/json",
22
+ authorization: `Bearer ${token}`,
23
+ ...(CLIENT ? { "x-hilos-client": CLIENT } : {}),
24
+ },
11
25
  body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
12
26
  });
13
27
  if (!res.ok) {