terminalhire 0.11.0 → 0.12.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.
@@ -4278,6 +4278,35 @@ async function defaultListPendingInvites(deps = {}) {
4278
4278
  const invites = listed.intros.filter((it) => it && it.role === "incoming" && it.status === "pending" && it.counterpartyLogin).map((it) => ({ login: it.counterpartyLogin }));
4279
4279
  return { status: "ok", invites };
4280
4280
  }
4281
+ function relativeTime(then, now = /* @__PURE__ */ new Date()) {
4282
+ const t = new Date(then).getTime();
4283
+ if (Number.isNaN(t)) return "";
4284
+ const deltaMs = Math.max(0, now.getTime() - t);
4285
+ const sec = Math.floor(deltaMs / 1e3);
4286
+ if (sec < 60) return "just now";
4287
+ const min = Math.floor(sec / 60);
4288
+ if (min < 60) return `${Math.max(1, min)}m ago`;
4289
+ const hr = Math.floor(min / 60);
4290
+ if (hr < 24) return `${hr}h ago`;
4291
+ const day = Math.floor(hr / 24);
4292
+ if (day < 7) return `${day}d ago`;
4293
+ const wk = Math.floor(day / 7);
4294
+ if (wk < 5) return `${wk}w ago`;
4295
+ return "a while ago";
4296
+ }
4297
+ function formatPresence(presence, now = /* @__PURE__ */ new Date()) {
4298
+ if (!presence) return "\u25CB not on chat yet";
4299
+ const share = presence.shareActivity === true;
4300
+ const seenMs = share && presence.lastSeen ? new Date(presence.lastSeen).getTime() : NaN;
4301
+ const hasSeen = !Number.isNaN(seenMs);
4302
+ const fresh = hasSeen && now.getTime() - seenMs <= ACTIVE_WINDOW_MS;
4303
+ if (share && presence.optin === true && fresh) return "\u25CF active now";
4304
+ if (share && hasSeen) {
4305
+ const rel = relativeTime(presence.lastSeen, now);
4306
+ return rel ? `\u25D0 reachable \xB7 seen ${rel}` : "\u25D0 reachable";
4307
+ }
4308
+ return "\u25D0 reachable";
4309
+ }
4281
4310
  var CHAT_BASE2, GH_SESSION_COOKIE2, ANSI_CSI, ANSI_OSC, ANSI_OTHER, C0_C1_DEL, CHAT_DISCLOSURE, CHAT_AT_REST, CHAT_CODE_OF_CONDUCT, CHAT_MIN_AGE, ACTIVE_WINDOW_MS;
4282
4311
  var init_jpi_chat = __esm({
4283
4312
  "bin/jpi-chat.js"() {
@@ -4404,7 +4433,7 @@ function renderInbox(items, invites = []) {
4404
4433
  lines.push(" (no accepted connections yet \u2014 request one: terminalhire intro <login>)");
4405
4434
  } else {
4406
4435
  for (const it of items) {
4407
- const dot = it.online ? "\u25CF" : "\u25CB";
4436
+ const dot = formatPresence(it.presence).charAt(0);
4408
4437
  const unread = it.unread > 0 ? `\u2709 ${it.unread}` : "\u2014";
4409
4438
  const login = `@${sanitizeLine(it.login)}`;
4410
4439
  const stamp = sanitizeLine(it.lastStamp || "");
@@ -4419,13 +4448,12 @@ function renderInbox(items, invites = []) {
4419
4448
  return lines.join("\n") + "\n";
4420
4449
  }
4421
4450
  function renderThread(state) {
4422
- const { peerLogin, online, safety, messages, total } = state;
4451
+ const { peerLogin, presence, safety, messages, total } = state;
4423
4452
  const safePeer = sanitizeLine(peerLogin);
4424
- const dot = online ? "\u25CF" : "\u25CB";
4425
- const presence = online ? "online" : "offline";
4453
+ const status = formatPresence(presence);
4426
4454
  const lines = [];
4427
4455
  lines.push(
4428
- ` @${safePeer} ${dot} ${presence}` + (safety ? ` \xB7 safety# ${sanitizeLine(safety)}` : "")
4456
+ ` @${safePeer} ${status}` + (safety ? ` \xB7 safety# ${sanitizeLine(safety)}` : "")
4429
4457
  );
4430
4458
  lines.push(" " + "\u2500".repeat(64));
4431
4459
  if (!messages || messages.length === 0) {
@@ -4475,21 +4503,6 @@ function writeProblem(output, result, target) {
4475
4503
  return "error";
4476
4504
  }
4477
4505
  }
4478
- async function readPresence(client, peerLogin) {
4479
- if (typeof client.getPeerPresence !== "function") return false;
4480
- try {
4481
- return await client.getPeerPresence(peerLogin) != null;
4482
- } catch {
4483
- return false;
4484
- }
4485
- }
4486
- async function clearPresence(client) {
4487
- if (typeof client.heartbeat !== "function") return;
4488
- try {
4489
- await client.heartbeat(false);
4490
- } catch {
4491
- }
4492
- }
4493
4506
  async function gateDisclosure(ensureDisclosure, input, output) {
4494
4507
  const ack = await ensureDisclosure({ input, output });
4495
4508
  if (!ack.acknowledged) {
@@ -4537,16 +4550,14 @@ async function runInbox(opts = {}) {
4537
4550
  (m) => m.senderLogin === conn.peerLogin && (!cursor || m.createdAt > cursor)
4538
4551
  ).length;
4539
4552
  const last = messages.length > 0 ? messages[messages.length - 1] : null;
4540
- const online = await readPresence(client, conn.peerLogin);
4541
4553
  items.push({
4542
4554
  login: conn.peerLogin,
4543
- online,
4555
+ presence: REACHABLE_DISPLAY,
4544
4556
  unread,
4545
4557
  lastStamp: last ? formatStamp(last.createdAt) : "",
4546
4558
  preview: last ? last.plaintext : ""
4547
4559
  });
4548
4560
  }
4549
- await clearPresence(client);
4550
4561
  output.write(renderInbox(items, invites));
4551
4562
  return { ok: true, count: items.length, invites: invites.length };
4552
4563
  }
@@ -4589,7 +4600,6 @@ async function runReadThread(opts = {}) {
4589
4600
  }
4590
4601
  const total = messages.length;
4591
4602
  const shownMessages = all ? messages : messages.slice(Math.max(0, total - limit));
4592
- const online = await readPresence(client, peerLogin);
4593
4603
  let safety = "";
4594
4604
  if (typeof client.getSafetyNumber === "function") {
4595
4605
  try {
@@ -4598,8 +4608,7 @@ async function runReadThread(opts = {}) {
4598
4608
  safety = "";
4599
4609
  }
4600
4610
  }
4601
- await clearPresence(client);
4602
- output.write(renderThread({ peerLogin, online, safety, messages: shownMessages, total }));
4611
+ output.write(renderThread({ peerLogin, presence: REACHABLE_DISPLAY, safety, messages: shownMessages, total }));
4603
4612
  if (total > 0) {
4604
4613
  const newest = messages[total - 1];
4605
4614
  if (newest && newest.createdAt) {
@@ -4672,7 +4681,7 @@ async function runSend(opts = {}) {
4672
4681
  );
4673
4682
  return { ok: true };
4674
4683
  }
4675
- var CHAT_BASE3, GH_SESSION_COOKIE3, TERMINALHIRE_DIR5, READS_FILE, INDEX_CACHE_FILE;
4684
+ var CHAT_BASE3, GH_SESSION_COOKIE3, TERMINALHIRE_DIR5, READS_FILE, INDEX_CACHE_FILE, REACHABLE_DISPLAY;
4676
4685
  var init_jpi_chat_read = __esm({
4677
4686
  "bin/jpi-chat-read.js"() {
4678
4687
  init_chat_client();
@@ -4683,6 +4692,7 @@ var init_jpi_chat_read = __esm({
4683
4692
  TERMINALHIRE_DIR5 = join8(homedir7(), ".terminalhire");
4684
4693
  READS_FILE = join8(TERMINALHIRE_DIR5, "chat-reads.json");
4685
4694
  INDEX_CACHE_FILE = join8(TERMINALHIRE_DIR5, "index-cache.json");
4695
+ REACHABLE_DISPLAY = { shareActivity: false, optin: false, lastSeen: null };
4686
4696
  }
4687
4697
  });
4688
4698
  init_jpi_chat_read();
@@ -4558,7 +4558,7 @@ function renderInbox(items, invites = []) {
4558
4558
  lines.push(" (no accepted connections yet \u2014 request one: terminalhire intro <login>)");
4559
4559
  } else {
4560
4560
  for (const it of items) {
4561
- const dot = it.online ? "\u25CF" : "\u25CB";
4561
+ const dot = formatPresence(it.presence).charAt(0);
4562
4562
  const unread = it.unread > 0 ? `\u2709 ${it.unread}` : "\u2014";
4563
4563
  const login = `@${sanitizeLine(it.login)}`;
4564
4564
  const stamp = sanitizeLine(it.lastStamp || "");
@@ -4573,13 +4573,12 @@ function renderInbox(items, invites = []) {
4573
4573
  return lines.join("\n") + "\n";
4574
4574
  }
4575
4575
  function renderThread(state) {
4576
- const { peerLogin, online, safety, messages, total } = state;
4576
+ const { peerLogin, presence, safety, messages, total } = state;
4577
4577
  const safePeer = sanitizeLine(peerLogin);
4578
- const dot = online ? "\u25CF" : "\u25CB";
4579
- const presence = online ? "online" : "offline";
4578
+ const status = formatPresence(presence);
4580
4579
  const lines = [];
4581
4580
  lines.push(
4582
- ` @${safePeer} ${dot} ${presence}` + (safety ? ` \xB7 safety# ${sanitizeLine(safety)}` : "")
4581
+ ` @${safePeer} ${status}` + (safety ? ` \xB7 safety# ${sanitizeLine(safety)}` : "")
4583
4582
  );
4584
4583
  lines.push(" " + "\u2500".repeat(64));
4585
4584
  if (!messages || messages.length === 0) {
@@ -4629,21 +4628,6 @@ function writeProblem(output, result, target) {
4629
4628
  return "error";
4630
4629
  }
4631
4630
  }
4632
- async function readPresence(client, peerLogin) {
4633
- if (typeof client.getPeerPresence !== "function") return false;
4634
- try {
4635
- return await client.getPeerPresence(peerLogin) != null;
4636
- } catch {
4637
- return false;
4638
- }
4639
- }
4640
- async function clearPresence(client) {
4641
- if (typeof client.heartbeat !== "function") return;
4642
- try {
4643
- await client.heartbeat(false);
4644
- } catch {
4645
- }
4646
- }
4647
4631
  async function gateDisclosure(ensureDisclosure, input, output) {
4648
4632
  const ack = await ensureDisclosure({ input, output });
4649
4633
  if (!ack.acknowledged) {
@@ -4691,16 +4675,14 @@ async function runInbox(opts = {}) {
4691
4675
  (m) => m.senderLogin === conn.peerLogin && (!cursor || m.createdAt > cursor)
4692
4676
  ).length;
4693
4677
  const last = messages.length > 0 ? messages[messages.length - 1] : null;
4694
- const online = await readPresence(client, conn.peerLogin);
4695
4678
  items.push({
4696
4679
  login: conn.peerLogin,
4697
- online,
4680
+ presence: REACHABLE_DISPLAY,
4698
4681
  unread,
4699
4682
  lastStamp: last ? formatStamp(last.createdAt) : "",
4700
4683
  preview: last ? last.plaintext : ""
4701
4684
  });
4702
4685
  }
4703
- await clearPresence(client);
4704
4686
  output.write(renderInbox(items, invites));
4705
4687
  return { ok: true, count: items.length, invites: invites.length };
4706
4688
  }
@@ -4743,7 +4725,6 @@ async function runReadThread(opts = {}) {
4743
4725
  }
4744
4726
  const total = messages.length;
4745
4727
  const shownMessages = all ? messages : messages.slice(Math.max(0, total - limit));
4746
- const online = await readPresence(client, peerLogin);
4747
4728
  let safety = "";
4748
4729
  if (typeof client.getSafetyNumber === "function") {
4749
4730
  try {
@@ -4752,8 +4733,7 @@ async function runReadThread(opts = {}) {
4752
4733
  safety = "";
4753
4734
  }
4754
4735
  }
4755
- await clearPresence(client);
4756
- output.write(renderThread({ peerLogin, online, safety, messages: shownMessages, total }));
4736
+ output.write(renderThread({ peerLogin, presence: REACHABLE_DISPLAY, safety, messages: shownMessages, total }));
4757
4737
  if (total > 0) {
4758
4738
  const newest = messages[total - 1];
4759
4739
  if (newest && newest.createdAt) {
@@ -4826,7 +4806,7 @@ async function runSend(opts = {}) {
4826
4806
  );
4827
4807
  return { ok: true };
4828
4808
  }
4829
- var CHAT_BASE2, GH_SESSION_COOKIE2, TERMINALHIRE_DIR6, READS_FILE, INDEX_CACHE_FILE;
4809
+ var CHAT_BASE2, GH_SESSION_COOKIE2, TERMINALHIRE_DIR6, READS_FILE, INDEX_CACHE_FILE, REACHABLE_DISPLAY;
4830
4810
  var init_jpi_chat_read = __esm({
4831
4811
  "bin/jpi-chat-read.js"() {
4832
4812
  "use strict";
@@ -4838,6 +4818,7 @@ var init_jpi_chat_read = __esm({
4838
4818
  TERMINALHIRE_DIR6 = join8(homedir7(), ".terminalhire");
4839
4819
  READS_FILE = join8(TERMINALHIRE_DIR6, "chat-reads.json");
4840
4820
  INDEX_CACHE_FILE = join8(TERMINALHIRE_DIR6, "index-cache.json");
4821
+ REACHABLE_DISPLAY = { shareActivity: false, optin: false, lastSeen: null };
4841
4822
  }
4842
4823
  });
4843
4824
 
@@ -10689,7 +10689,7 @@ function renderInbox(items, invites = []) {
10689
10689
  lines.push(" (no accepted connections yet \u2014 request one: terminalhire intro <login>)");
10690
10690
  } else {
10691
10691
  for (const it of items) {
10692
- const dot = it.online ? "\u25CF" : "\u25CB";
10692
+ const dot = formatPresence(it.presence).charAt(0);
10693
10693
  const unread = it.unread > 0 ? `\u2709 ${it.unread}` : "\u2014";
10694
10694
  const login = `@${sanitizeLine(it.login)}`;
10695
10695
  const stamp = sanitizeLine(it.lastStamp || "");
@@ -10704,13 +10704,12 @@ function renderInbox(items, invites = []) {
10704
10704
  return lines.join("\n") + "\n";
10705
10705
  }
10706
10706
  function renderThread(state) {
10707
- const { peerLogin, online, safety, messages, total } = state;
10707
+ const { peerLogin, presence, safety, messages, total } = state;
10708
10708
  const safePeer = sanitizeLine(peerLogin);
10709
- const dot = online ? "\u25CF" : "\u25CB";
10710
- const presence = online ? "online" : "offline";
10709
+ const status = formatPresence(presence);
10711
10710
  const lines = [];
10712
10711
  lines.push(
10713
- ` @${safePeer} ${dot} ${presence}` + (safety ? ` \xB7 safety# ${sanitizeLine(safety)}` : "")
10712
+ ` @${safePeer} ${status}` + (safety ? ` \xB7 safety# ${sanitizeLine(safety)}` : "")
10714
10713
  );
10715
10714
  lines.push(" " + "\u2500".repeat(64));
10716
10715
  if (!messages || messages.length === 0) {
@@ -10760,21 +10759,6 @@ function writeProblem(output, result, target) {
10760
10759
  return "error";
10761
10760
  }
10762
10761
  }
10763
- async function readPresence(client, peerLogin) {
10764
- if (typeof client.getPeerPresence !== "function") return false;
10765
- try {
10766
- return await client.getPeerPresence(peerLogin) != null;
10767
- } catch {
10768
- return false;
10769
- }
10770
- }
10771
- async function clearPresence(client) {
10772
- if (typeof client.heartbeat !== "function") return;
10773
- try {
10774
- await client.heartbeat(false);
10775
- } catch {
10776
- }
10777
- }
10778
10762
  async function gateDisclosure(ensureDisclosure, input, output) {
10779
10763
  const ack = await ensureDisclosure({ input, output });
10780
10764
  if (!ack.acknowledged) {
@@ -10822,16 +10806,14 @@ async function runInbox(opts = {}) {
10822
10806
  (m) => m.senderLogin === conn.peerLogin && (!cursor || m.createdAt > cursor)
10823
10807
  ).length;
10824
10808
  const last = messages.length > 0 ? messages[messages.length - 1] : null;
10825
- const online = await readPresence(client, conn.peerLogin);
10826
10809
  items.push({
10827
10810
  login: conn.peerLogin,
10828
- online,
10811
+ presence: REACHABLE_DISPLAY,
10829
10812
  unread,
10830
10813
  lastStamp: last ? formatStamp(last.createdAt) : "",
10831
10814
  preview: last ? last.plaintext : ""
10832
10815
  });
10833
10816
  }
10834
- await clearPresence(client);
10835
10817
  output.write(renderInbox(items, invites));
10836
10818
  return { ok: true, count: items.length, invites: invites.length };
10837
10819
  }
@@ -10874,7 +10856,6 @@ async function runReadThread(opts = {}) {
10874
10856
  }
10875
10857
  const total = messages.length;
10876
10858
  const shownMessages = all ? messages : messages.slice(Math.max(0, total - limit));
10877
- const online = await readPresence(client, peerLogin);
10878
10859
  let safety = "";
10879
10860
  if (typeof client.getSafetyNumber === "function") {
10880
10861
  try {
@@ -10883,8 +10864,7 @@ async function runReadThread(opts = {}) {
10883
10864
  safety = "";
10884
10865
  }
10885
10866
  }
10886
- await clearPresence(client);
10887
- output.write(renderThread({ peerLogin, online, safety, messages: shownMessages, total }));
10867
+ output.write(renderThread({ peerLogin, presence: REACHABLE_DISPLAY, safety, messages: shownMessages, total }));
10888
10868
  if (total > 0) {
10889
10869
  const newest = messages[total - 1];
10890
10870
  if (newest && newest.createdAt) {
@@ -10957,7 +10937,7 @@ async function runSend(opts = {}) {
10957
10937
  );
10958
10938
  return { ok: true };
10959
10939
  }
10960
- var CHAT_BASE2, GH_SESSION_COOKIE4, TERMINALHIRE_DIR13, READS_FILE, INDEX_CACHE_FILE5;
10940
+ var CHAT_BASE2, GH_SESSION_COOKIE4, TERMINALHIRE_DIR13, READS_FILE, INDEX_CACHE_FILE5, REACHABLE_DISPLAY;
10961
10941
  var init_jpi_chat_read = __esm({
10962
10942
  "bin/jpi-chat-read.js"() {
10963
10943
  "use strict";
@@ -10969,6 +10949,7 @@ var init_jpi_chat_read = __esm({
10969
10949
  TERMINALHIRE_DIR13 = join17(homedir16(), ".terminalhire");
10970
10950
  READS_FILE = join17(TERMINALHIRE_DIR13, "chat-reads.json");
10971
10951
  INDEX_CACHE_FILE5 = join17(TERMINALHIRE_DIR13, "index-cache.json");
10952
+ REACHABLE_DISPLAY = { shareActivity: false, optin: false, lastSeen: null };
10972
10953
  }
10973
10954
  });
10974
10955
 
@@ -13370,6 +13351,13 @@ function resolveInstallJs() {
13370
13351
  if (existsSync18(fromBin)) return fromBin;
13371
13352
  return fromBin;
13372
13353
  }
13354
+ function resolveStatuslineInstallJs() {
13355
+ const fromDist = resolve(join24(__dirname3, "..", "..", "statusline-install.js"));
13356
+ const fromBin = resolve(join24(__dirname3, "..", "statusline-install.js"));
13357
+ if (existsSync18(fromDist)) return fromDist;
13358
+ if (existsSync18(fromBin)) return fromBin;
13359
+ return fromBin;
13360
+ }
13373
13361
  async function run17() {
13374
13362
  console.log("");
13375
13363
  console.log("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
@@ -13382,10 +13370,12 @@ async function run17() {
13382
13370
  console.log(" 2. Seed your local job cache (anonymous index download)");
13383
13371
  console.log(" 3. Enable the ambient spinner job surface in ~/.claude/settings.json");
13384
13372
  console.log(" (with backup + your explicit consent before any file is touched)");
13373
+ console.log(" 4. Optionally show connection notifications in your statusLine");
13374
+ console.log(" (\u{1F4AC} unread + intro requests only \u2014 never job ads; separate consent)");
13385
13375
  console.log("");
13386
13376
  console.log('You can stop at any step. Nothing is changed until you say "yes".');
13387
13377
  console.log("");
13388
- console.log("Step 1/3 \u2014 GitHub sign-in (optional but recommended)");
13378
+ console.log("Step 1/4 \u2014 GitHub sign-in (optional but recommended)");
13389
13379
  console.log("");
13390
13380
  console.log(" Scope: read:user \u2014 public profile + public repos only.");
13391
13381
  console.log(" Your token is encrypted at ~/.terminalhire/github-token.enc.");
@@ -13413,7 +13403,7 @@ async function run17() {
13413
13403
  console.log(" Sign in any time with: terminalhire login");
13414
13404
  }
13415
13405
  console.log("");
13416
- console.log("Step 2/3 \u2014 Seeding local job cache");
13406
+ console.log("Step 2/4 \u2014 Seeding local job cache");
13417
13407
  console.log("");
13418
13408
  console.log(" Fetching anonymous job index (no dev data sent)...");
13419
13409
  const jobsScript = resolveScript("jpi-jobs");
@@ -13433,7 +13423,7 @@ async function run17() {
13433
13423
  console.log(" Run `terminalhire jobs` after a few Claude Code sessions to populate it.");
13434
13424
  }
13435
13425
  console.log("");
13436
- console.log("Step 3/3 \u2014 Enable the ambient spinner job surface in ~/.claude/settings.json");
13426
+ console.log("Step 3/4 \u2014 Enable the ambient spinner job surface in ~/.claude/settings.json");
13437
13427
  console.log("");
13438
13428
  console.log(" This is the only step that modifies a system file.");
13439
13429
  console.log(" A timestamped backup is created before any change.");
@@ -13449,6 +13439,24 @@ async function run17() {
13449
13439
  console.log(" Hook installation did not complete. Run manually: node install.js");
13450
13440
  }
13451
13441
  console.log("");
13442
+ console.log("Step 4/4 \u2014 Connection notifications in your statusLine (optional)");
13443
+ console.log("");
13444
+ console.log(" A statusLine that shows ONLY personal connection signals \u2014 \u{1F4AC} unread");
13445
+ console.log(" messages and inbound intro requests. Never job ads (those stay in the");
13446
+ console.log(" spinner). Local cache read, zero network. Separate consent + backup;");
13447
+ console.log(" it stays current across plugin updates and preserves any existing");
13448
+ console.log(" statusLine you have. Remove any time: node statusline-install.js --uninstall");
13449
+ console.log("");
13450
+ const statuslineInstallJs = resolveStatuslineInstallJs();
13451
+ const statuslineChild = spawnSync(process.execPath, [statuslineInstallJs], {
13452
+ stdio: ["inherit", "inherit", "inherit"],
13453
+ env: process.env
13454
+ });
13455
+ if (statuslineChild.status !== 0) {
13456
+ console.log("");
13457
+ console.log(" statusLine setup did not complete. Run manually: node statusline-install.js");
13458
+ }
13459
+ console.log("");
13452
13460
  console.log("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
13453
13461
  console.log("\u2502 terminalhire init complete! \u2502");
13454
13462
  console.log("\u2502 \u2502");
@@ -29,6 +29,13 @@ function resolveInstallJs() {
29
29
  if (existsSync(fromBin)) return fromBin;
30
30
  return fromBin;
31
31
  }
32
+ function resolveStatuslineInstallJs() {
33
+ const fromDist = resolve(join(__dirname, "..", "..", "statusline-install.js"));
34
+ const fromBin = resolve(join(__dirname, "..", "statusline-install.js"));
35
+ if (existsSync(fromDist)) return fromDist;
36
+ if (existsSync(fromBin)) return fromBin;
37
+ return fromBin;
38
+ }
32
39
  async function run() {
33
40
  console.log("");
34
41
  console.log("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
@@ -41,10 +48,12 @@ async function run() {
41
48
  console.log(" 2. Seed your local job cache (anonymous index download)");
42
49
  console.log(" 3. Enable the ambient spinner job surface in ~/.claude/settings.json");
43
50
  console.log(" (with backup + your explicit consent before any file is touched)");
51
+ console.log(" 4. Optionally show connection notifications in your statusLine");
52
+ console.log(" (\u{1F4AC} unread + intro requests only \u2014 never job ads; separate consent)");
44
53
  console.log("");
45
54
  console.log('You can stop at any step. Nothing is changed until you say "yes".');
46
55
  console.log("");
47
- console.log("Step 1/3 \u2014 GitHub sign-in (optional but recommended)");
56
+ console.log("Step 1/4 \u2014 GitHub sign-in (optional but recommended)");
48
57
  console.log("");
49
58
  console.log(" Scope: read:user \u2014 public profile + public repos only.");
50
59
  console.log(" Your token is encrypted at ~/.terminalhire/github-token.enc.");
@@ -72,7 +81,7 @@ async function run() {
72
81
  console.log(" Sign in any time with: terminalhire login");
73
82
  }
74
83
  console.log("");
75
- console.log("Step 2/3 \u2014 Seeding local job cache");
84
+ console.log("Step 2/4 \u2014 Seeding local job cache");
76
85
  console.log("");
77
86
  console.log(" Fetching anonymous job index (no dev data sent)...");
78
87
  const jobsScript = resolveScript("jpi-jobs");
@@ -92,7 +101,7 @@ async function run() {
92
101
  console.log(" Run `terminalhire jobs` after a few Claude Code sessions to populate it.");
93
102
  }
94
103
  console.log("");
95
- console.log("Step 3/3 \u2014 Enable the ambient spinner job surface in ~/.claude/settings.json");
104
+ console.log("Step 3/4 \u2014 Enable the ambient spinner job surface in ~/.claude/settings.json");
96
105
  console.log("");
97
106
  console.log(" This is the only step that modifies a system file.");
98
107
  console.log(" A timestamped backup is created before any change.");
@@ -108,6 +117,24 @@ async function run() {
108
117
  console.log(" Hook installation did not complete. Run manually: node install.js");
109
118
  }
110
119
  console.log("");
120
+ console.log("Step 4/4 \u2014 Connection notifications in your statusLine (optional)");
121
+ console.log("");
122
+ console.log(" A statusLine that shows ONLY personal connection signals \u2014 \u{1F4AC} unread");
123
+ console.log(" messages and inbound intro requests. Never job ads (those stay in the");
124
+ console.log(" spinner). Local cache read, zero network. Separate consent + backup;");
125
+ console.log(" it stays current across plugin updates and preserves any existing");
126
+ console.log(" statusLine you have. Remove any time: node statusline-install.js --uninstall");
127
+ console.log("");
128
+ const statuslineInstallJs = resolveStatuslineInstallJs();
129
+ const statuslineChild = spawnSync(process.execPath, [statuslineInstallJs], {
130
+ stdio: ["inherit", "inherit", "inherit"],
131
+ env: process.env
132
+ });
133
+ if (statuslineChild.status !== 0) {
134
+ console.log("");
135
+ console.log(" statusLine setup did not complete. Run manually: node statusline-install.js");
136
+ }
137
+ console.log("");
111
138
  console.log("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
112
139
  console.log("\u2502 terminalhire init complete! \u2502");
113
140
  console.log("\u2502 \u2502");
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/jpi-statusline-launch.js
4
+ import { readFileSync, existsSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+ import { spawnSync } from "child_process";
8
+ import { pathToFileURL } from "url";
9
+ var TH_DIR = join(homedir(), ".terminalhire");
10
+ var POINTER = join(TH_DIR, "engine-path");
11
+ var FOREIGN = join(TH_DIR, "statusline-foreign.json");
12
+ function firstLine(buf) {
13
+ if (!buf || !buf.length) return "";
14
+ const s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
15
+ const nl = s.indexOf("\n");
16
+ return (nl === -1 ? s : s.slice(0, nl)).replace(/\s+$/u, "");
17
+ }
18
+ async function renderOurs(input) {
19
+ let engine = "";
20
+ try {
21
+ if (!existsSync(POINTER)) return "";
22
+ engine = readFileSync(POINTER, "utf8").trim();
23
+ } catch {
24
+ return "";
25
+ }
26
+ if (!engine || !existsSync(engine)) return "";
27
+ try {
28
+ const line = await Promise.race([
29
+ import(pathToFileURL(engine).href).then(
30
+ (mod) => mod && typeof mod.render === "function" ? firstLine(mod.render()) : null
31
+ ),
32
+ new Promise((resolve) => setTimeout(() => resolve(void 0), 30))
33
+ ]);
34
+ if (typeof line === "string") return line;
35
+ } catch {
36
+ }
37
+ try {
38
+ const r = spawnSync(process.execPath, [engine], { input, timeout: 800 });
39
+ return firstLine(r.stdout);
40
+ } catch {
41
+ return "";
42
+ }
43
+ }
44
+ function renderForeign(input) {
45
+ try {
46
+ if (!existsSync(FOREIGN)) return "";
47
+ const saved = JSON.parse(readFileSync(FOREIGN, "utf8"));
48
+ const cmd = saved && saved.statusLine && saved.statusLine.command;
49
+ if (!cmd) return "";
50
+ const r = spawnSync(cmd, { shell: true, input, timeout: 4e3 });
51
+ return firstLine(r.stdout);
52
+ } catch {
53
+ return "";
54
+ }
55
+ }
56
+ try {
57
+ let input = Buffer.alloc(0);
58
+ try {
59
+ if (!process.stdin.isTTY) input = readFileSync(0);
60
+ } catch {
61
+ }
62
+ const segments = [];
63
+ const foreign = renderForeign(input);
64
+ if (foreign) segments.push(foreign);
65
+ const ours = await renderOurs(input);
66
+ if (ours) segments.push(ours);
67
+ if (segments.length > 0) process.stdout.write(segments.join(" \xB7 ") + "\n");
68
+ process.exit(0);
69
+ } catch {
70
+ process.exit(0);
71
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/jpi-statusline.js
4
+ import { readFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+ import { pathToFileURL } from "url";
8
+ var INDEX_CACHE_FILE = join(homedir(), ".terminalhire", "index-cache.json");
9
+ var INDEX_CACHE_TTL_MS = 15 * 60 * 1e3;
10
+ function readFreshCache() {
11
+ try {
12
+ const entry = JSON.parse(readFileSync(INDEX_CACHE_FILE, "utf8"));
13
+ if (typeof entry.ts !== "number" || Date.now() - entry.ts > INDEX_CACHE_TTL_MS) return null;
14
+ return entry;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ function unreadChatCount(entry) {
20
+ const n = entry && entry.unreadChat && entry.unreadChat.count;
21
+ return typeof n === "number" && n > 0 ? n : 0;
22
+ }
23
+ function incomingCount(entry) {
24
+ const n = entry && entry.incomingPending && entry.incomingPending.count;
25
+ return typeof n === "number" && n > 0 ? n : 0;
26
+ }
27
+ function sessionStale(entry) {
28
+ return !!entry && entry.sessionStale === true;
29
+ }
30
+ function render() {
31
+ try {
32
+ const entry = readFreshCache();
33
+ if (!entry) return "";
34
+ const unread = unreadChatCount(entry);
35
+ const incoming = incomingCount(entry);
36
+ const stale = sessionStale(entry) && unread === 0 && incoming === 0;
37
+ const parts = [];
38
+ if (unread > 0) parts.push(`\u{1F4AC} ${unread} unread`);
39
+ if (incoming > 0) parts.push(`\u2709 ${incoming} waiting to connect`);
40
+ if (parts.length > 0) {
41
+ let line = parts.join(" \xB7 ");
42
+ line += unread > 0 ? " \u2014 run: terminalhire chat" : " \u2014 run: terminalhire intro --list";
43
+ return line;
44
+ }
45
+ if (stale) {
46
+ return "\u26A0 terminalhire session expired \u2014 run: terminalhire link";
47
+ }
48
+ return "";
49
+ } catch {
50
+ return "";
51
+ }
52
+ }
53
+ try {
54
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
55
+ if (isMain) {
56
+ const line = render();
57
+ if (line) process.stdout.write(line + "\n");
58
+ process.exit(0);
59
+ }
60
+ } catch {
61
+ process.exit(0);
62
+ }
63
+ export {
64
+ render
65
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminalhire",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Local-first job matching for developers — ambient job matches in the Claude Code spinner. Matching runs on your machine; your profile never leaves it.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,13 +22,14 @@
22
22
  "dist",
23
23
  "fixtures",
24
24
  "install.js",
25
+ "statusline-install.js",
25
26
  "postinstall.js",
26
27
  "README.md"
27
28
  ],
28
29
  "scripts": {
29
30
  "test": "for f in test/*.test.js; do echo \"# $f\"; node \"$f\" || exit 1; done",
30
31
  "build": "tsup",
31
- "bundle:plugin": "npm run build && rm -rf ../../plugins/terminalhire/dist && cp -R dist ../../plugins/terminalhire/dist && cp package.json ../../plugins/terminalhire/dist/package.json && cp install.js ../../plugins/terminalhire/dist/install.js && cp postinstall.js ../../plugins/terminalhire/dist/postinstall.js",
32
+ "bundle:plugin": "npm run build && rm -rf ../../plugins/terminalhire/dist && cp -R dist ../../plugins/terminalhire/dist && cp package.json ../../plugins/terminalhire/dist/package.json && cp install.js ../../plugins/terminalhire/dist/install.js && cp statusline-install.js ../../plugins/terminalhire/dist/statusline-install.js && cp postinstall.js ../../plugins/terminalhire/dist/postinstall.js",
32
33
  "prepublishOnly": "npm run build",
33
34
  "install-hook": "node install.js",
34
35
  "postinstall": "node ./postinstall.js"
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * statusline-install.js — consent-gated installer for the CONNECTION-ONLY statusLine
4
+ * (feature 002 / ADR-002). Companion to install.js (which is spinner-only and, by its
5
+ * binding rule, NEVER writes settings.statusLine). This file is the ONLY writer of
6
+ * settings.statusLine, and it writes ONLY the self-updating launcher command — the
7
+ * statusLine it enables carries personal connection notifications, never job ads
8
+ * (the entire justification for re-opening the demoted statusLine surface).
9
+ *
10
+ * Guardrails (CONSTITUTION Rule 12 / ~/.claude/rules/safety.md):
11
+ * - Explicit typed "yes" BEFORE touching any system file (no-consent → settings.json
12
+ * byte-for-byte unchanged, no backup churn — V-4).
13
+ * - Timestamped backup of ~/.claude/settings.json before any write.
14
+ * - settings.statusLine.command is ZERO-VOLATILE: `node ~/.terminalhire/statusline-launch.js`
15
+ * — a stable, version-agnostic path (V-1). All version logic lives behind the
16
+ * hook-written engine-path pointer, so this artifact is written once and never
17
+ * re-stamped → it cannot recreate the stale-wrapper bug.
18
+ * - Chain-never-clobber: a pre-existing FOREIGN statusLine is saved verbatim to a
19
+ * sidecar and run FIRST by the launcher; uninstall restores it byte-for-byte (V-5),
20
+ * and it still renders when our part is empty (V-8).
21
+ * - Legacy re-point (Q4): an old terminalhire wrapper (npm-pinned .sh) is detected and,
22
+ * with a targeted yes, replaced by the self-updating launcher — never silently.
23
+ *
24
+ * Usage:
25
+ * node statusline-install.js
26
+ * node statusline-install.js --uninstall
27
+ */
28
+
29
+ import {
30
+ readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync, rmSync,
31
+ } from 'node:fs';
32
+ import { homedir } from 'node:os';
33
+ import { join, resolve, dirname } from 'node:path';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
35
+ import { createInterface } from 'node:readline';
36
+
37
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
38
+
39
+ const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
40
+ const SETTINGS_DIR = dirname(SETTINGS_PATH);
41
+ const TERMINALHIRE_DIR = join(homedir(), '.terminalhire');
42
+ const STABLE_LAUNCHER = join(TERMINALHIRE_DIR, 'statusline-launch.js');
43
+ const FOREIGN_SIDECAR = join(TERMINALHIRE_DIR, 'statusline-foreign.json');
44
+ const LEGACY_WRAPPER = join(TERMINALHIRE_DIR, 'statusline-wrapper.sh');
45
+
46
+ // The exact command written to settings.statusLine — stable, version-agnostic (V-1).
47
+ const LAUNCHER_COMMAND = `node ${STABLE_LAUNCHER}`;
48
+
49
+ const UNINSTALL = process.argv.includes('--uninstall');
50
+
51
+ // ── Helpers ───────────────────────────────────────────────────────────────────
52
+
53
+ function ask(question) {
54
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
55
+ return new Promise(res => {
56
+ rl.question(question, answer => {
57
+ rl.close();
58
+ res(answer.trim().toLowerCase());
59
+ });
60
+ });
61
+ }
62
+
63
+ function backupSettings() {
64
+ if (!existsSync(SETTINGS_PATH)) return null;
65
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
66
+ const backupPath = `${SETTINGS_PATH}.terminalhire-backup-${ts}`;
67
+ copyFileSync(SETTINGS_PATH, backupPath);
68
+ console.log(` Backed up settings to: ${backupPath}`);
69
+ return backupPath;
70
+ }
71
+
72
+ function readSettings() {
73
+ try {
74
+ if (existsSync(SETTINGS_PATH)) return JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
75
+ } catch { /* fall through to empty */ }
76
+ return {};
77
+ }
78
+
79
+ function writeSettings(settings) {
80
+ mkdirSync(SETTINGS_DIR, { recursive: true });
81
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
82
+ }
83
+
84
+ // Resolve the launcher SOURCE (bundled dist preferred; bin fallback for the dev workspace).
85
+ function resolveLauncherSource() {
86
+ const candidates = [
87
+ resolve(join(__dirname, 'dist', 'bin', 'jpi-statusline-launch.js')),
88
+ resolve(join(__dirname, 'bin', 'jpi-statusline-launch.js')),
89
+ ];
90
+ for (const c of candidates) if (existsSync(c)) return c;
91
+ return null;
92
+ }
93
+
94
+ // Classify an existing statusLine command:
95
+ // 'ours' — already the self-updating launcher (idempotent)
96
+ // 'legacy' — an OLD terminalhire wrapper (npm-pinned .sh / dispatch) → offer re-point
97
+ // 'foreign' — someone else's statusLine → chain-wrap + preserve
98
+ // 'none' — no command
99
+ function classifyExisting(statusLine) {
100
+ const cmd = statusLine && typeof statusLine.command === 'string' ? statusLine.command : '';
101
+ if (!cmd) return 'none';
102
+ if (cmd.includes('statusline-launch.js')) return 'ours';
103
+ if (
104
+ cmd.includes('statusline-wrapper.sh') ||
105
+ existsSync(LEGACY_WRAPPER) && cmd.includes('.terminalhire') ||
106
+ cmd.includes('jpi-dispatch') ||
107
+ /terminalhire|\bjpi\b|jpi-/.test(cmd)
108
+ ) {
109
+ return 'legacy';
110
+ }
111
+ return 'foreign';
112
+ }
113
+
114
+ // ── Install ───────────────────────────────────────────────────────────────────
115
+
116
+ async function install() {
117
+ console.log('');
118
+ console.log('┌─────────────────────────────────────────────────────────────────┐');
119
+ console.log('│ terminalhire — connection notifications in your statusLine │');
120
+ console.log('│ Personal connection signals only. Never job ads. │');
121
+ console.log('└─────────────────────────────────────────────────────────────────┘');
122
+ console.log('');
123
+ console.log('DISCLOSURE — read before enabling');
124
+ console.log('');
125
+ console.log('WHAT THIS ENABLES:');
126
+ console.log(' A Claude Code statusLine that shows ONLY personal connection signals:');
127
+ console.log(' • 💬 N unread — messages from developers you accepted an intro with');
128
+ console.log(' • ✉ N waiting to connect — inbound intro requests awaiting your reply');
129
+ console.log(' • ⚠ session expired — a re-link hint when your linked session lapses');
130
+ console.log(' It NEVER shows job matches, role %-scores, or any advert — those stay in');
131
+ console.log(' the ambient spinner. The line is a LOCAL cache read: zero network calls.');
132
+ console.log('');
133
+ console.log('HOW IT STAYS CURRENT (no manual re-install ever):');
134
+ console.log(' settings.json gets a stable command: ' + LAUNCHER_COMMAND);
135
+ console.log(' On every session the plugin refreshes a pointer to its current engine, so');
136
+ console.log(' the statusLine follows plugin auto-updates automatically.');
137
+ console.log('');
138
+ console.log('WHAT THIS CHANGES:');
139
+ console.log(' • ~/.claude/settings.json — sets statusLine to the launcher above.');
140
+ console.log(' • A timestamped backup is created before any change.');
141
+ console.log(' • Any statusLine you already have is PRESERVED (run first) — never replaced.');
142
+ console.log('');
143
+ console.log('HOW TO REMOVE:');
144
+ console.log(' • node statusline-install.js --uninstall (restores your prior statusLine)');
145
+ console.log('');
146
+
147
+ const answer = await ask('Enable the connection-notification statusLine? Type "yes" to continue: ');
148
+ if (answer !== 'yes') {
149
+ console.log('\nAborted — nothing was changed.');
150
+ process.exit(0);
151
+ }
152
+
153
+ const src = resolveLauncherSource();
154
+ if (!src) {
155
+ console.log('\n Could not locate the statusLine launcher — nothing was changed.');
156
+ console.log(' Rebuild the package (npm run build) and try again.');
157
+ process.exit(1);
158
+ }
159
+
160
+ const settings = readSettings();
161
+ const existing = settings.statusLine;
162
+ const kind = classifyExisting(existing);
163
+
164
+ // Legacy re-point (Q4) — consent-gated, never silent.
165
+ if (kind === 'legacy') {
166
+ console.log('');
167
+ console.log(' Found an older terminalhire statusLine (npm-pinned — can go stale).');
168
+ const rp = await ask(' Replace it with the self-updating launcher? Type "yes": ');
169
+ if (rp !== 'yes') {
170
+ console.log('\n Left your existing statusLine as-is — nothing was changed.');
171
+ process.exit(0);
172
+ }
173
+ // It is our own legacy artifact → do NOT preserve it as "foreign".
174
+ }
175
+
176
+ // Only NOW (post-consent) do we touch the filesystem — V-4.
177
+ console.log('');
178
+ mkdirSync(TERMINALHIRE_DIR, { recursive: true });
179
+
180
+ // Copy the launcher to its stable, version-agnostic home.
181
+ copyFileSync(src, STABLE_LAUNCHER);
182
+
183
+ // Chain-wrap a genuinely FOREIGN statusLine: save it verbatim so the launcher runs it
184
+ // first and uninstall can restore it byte-for-byte (V-5/V-8).
185
+ if (kind === 'foreign') {
186
+ writeFileSync(FOREIGN_SIDECAR, JSON.stringify({ statusLine: existing }, null, 2) + '\n', 'utf8');
187
+ console.log(' Preserved your existing statusLine — it will render alongside ours.');
188
+ } else if (kind === 'ours' && existsSync(FOREIGN_SIDECAR)) {
189
+ // Idempotent re-run: keep any previously-saved foreign statusLine sidecar.
190
+ }
191
+
192
+ const backupPath = backupSettings();
193
+
194
+ settings.statusLine = { type: 'command', command: LAUNCHER_COMMAND };
195
+ writeSettings(settings);
196
+
197
+ console.log(' statusLine: ENABLED');
198
+ console.log(` Command: ${LAUNCHER_COMMAND}`);
199
+ if (backupPath) console.log(` Backup: ${backupPath}`);
200
+ console.log('');
201
+ console.log('Done. Restart Claude Code to see connection notifications in your statusLine.');
202
+ console.log(' Remove any time: node statusline-install.js --uninstall');
203
+ console.log('');
204
+ }
205
+
206
+ // ── Uninstall ─────────────────────────────────────────────────────────────────
207
+
208
+ async function uninstall() {
209
+ console.log('');
210
+ console.log('terminalhire — remove the connection-notification statusLine');
211
+ console.log('');
212
+ console.log(' This restores whatever statusLine you had before (byte-for-byte), or');
213
+ console.log(' removes the statusLine entirely if terminalhire added it fresh.');
214
+ console.log('');
215
+
216
+ const answer = await ask('Remove the terminalhire statusLine? Type "yes" to continue: ');
217
+ if (answer !== 'yes') {
218
+ console.log('\nAborted — nothing was changed.');
219
+ process.exit(0);
220
+ }
221
+
222
+ console.log('');
223
+ const settings = readSettings();
224
+ const isOurs = settings.statusLine
225
+ && typeof settings.statusLine.command === 'string'
226
+ && settings.statusLine.command.includes('statusline-launch.js');
227
+
228
+ // Only mutate settings if the current statusLine is ours (never clobber a foreign one
229
+ // the user re-added after install).
230
+ if (isOurs) {
231
+ const backupPath = backupSettings();
232
+ if (existsSync(FOREIGN_SIDECAR)) {
233
+ try {
234
+ const saved = JSON.parse(readFileSync(FOREIGN_SIDECAR, 'utf8'));
235
+ if (saved && saved.statusLine) settings.statusLine = saved.statusLine; // byte-for-byte (V-5)
236
+ else delete settings.statusLine;
237
+ } catch {
238
+ delete settings.statusLine;
239
+ }
240
+ console.log(' Restored your previous statusLine.');
241
+ } else {
242
+ delete settings.statusLine;
243
+ console.log(' Removed the terminalhire statusLine.');
244
+ }
245
+ writeSettings(settings);
246
+ if (backupPath) console.log(` Backup: ${backupPath}`);
247
+ } else {
248
+ console.log(' No terminalhire statusLine is currently active — settings.json left unchanged.');
249
+ }
250
+
251
+ // Clean up our stable artifacts (the pointer is re-written harmlessly by the hook).
252
+ try { if (existsSync(FOREIGN_SIDECAR)) rmSync(FOREIGN_SIDECAR); } catch { /* best-effort */ }
253
+ try { if (existsSync(STABLE_LAUNCHER)) rmSync(STABLE_LAUNCHER); } catch { /* best-effort */ }
254
+
255
+ console.log('');
256
+ console.log('Done. Restart Claude Code to apply.');
257
+ console.log('');
258
+ }
259
+
260
+ // ── Entry ─────────────────────────────────────────────────────────────────────
261
+
262
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
263
+ if (isMain) {
264
+ const run = UNINSTALL ? uninstall : install;
265
+ run().catch(err => {
266
+ console.error(`${UNINSTALL ? 'Uninstall' : 'Install'} error:`, err.message);
267
+ process.exit(1);
268
+ });
269
+ }
270
+
271
+ export { install as installStatusline, uninstall as uninstallStatusline, LAUNCHER_COMMAND };