itwillsync 1.2.0 → 1.3.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.
@@ -0,0 +1,2 @@
1
+ (function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))o(a);new MutationObserver(a=>{for(const s of a)if(s.type==="childList")for(const i of s.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&o(i)}).observe(document,{childList:!0,subtree:!0});function n(a){const s={};return a.integrity&&(s.integrity=a.integrity),a.referrerPolicy&&(s.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?s.credentials="include":a.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function o(a){if(a.ep)return;a.ep=!0;const s=n(a);fetch(a.href,s)}})();function B(e){const t=Math.floor(e/1e3);if(t<60)return`${t}s`;const n=Math.floor(t/60);if(n<60)return`${n}m`;const o=Math.floor(n/60),a=n%60;return`${o}h ${a}m`}function z(e){const t=e.match(/^\/(?:Users|home)\/[^/]+/)?.[0];return t?"~"+e.slice(t.length):e}function X(e){return e<1024?`${e} KB`:`${(e/1024).toFixed(1)} MB`}function H(e,t,n){const o=document.createElement("div");o.className=`session-card${e.status==="attention"?" attention":""}`,o.dataset.sessionId=e.id;const a=B(Date.now()-e.connectedAt),s=z(e.cwd),i=document.createElement("div");i.className="card-header";const c=document.createElement("div");c.className="card-agent";const p=document.createElement("div");p.className=`agent-dot ${e.status}`;const P=document.createElement("span");P.className="agent-name",P.textContent=e.name||e.agent,c.appendChild(p),c.appendChild(P);const k=document.createElement("span");k.className="card-uptime",k.textContent=a,i.appendChild(c),i.appendChild(k);const I=document.createElement("div");I.className="card-cwd",I.textContent=s;const h=document.createElement("div");h.className="card-preview";const $=document.createElement("pre");$.className="card-preview-text",$.textContent="Waiting for output...",h.appendChild($),h.addEventListener("click",l=>{l.stopPropagation(),n.onOpen(e)});const g=document.createElement("div");g.className="card-status";const E=document.createElement("span");E.className=`status-badge ${e.status}`,E.textContent=e.status,e.status==="attention"&&(E.style.display="none");const v=document.createElement("span");v.className="attention-badge",v.textContent="Needs your attention",e.status!=="attention"&&(v.style.display="none"),g.appendChild(E),g.appendChild(v);const f=document.createElement("div");f.className="card-actions";const C=document.createElement("button");C.className="action-btn stop",C.textContent="Stop",C.addEventListener("click",l=>{l.stopPropagation(),Q(o,e.id,n.onStop)});const w=document.createElement("button");w.className="action-btn rename",w.textContent="Rename",w.addEventListener("click",l=>{l.stopPropagation(),Z(o,e.id,n.onRename)});const N=document.createElement("button");N.className="action-btn info",N.textContent="Info",N.addEventListener("click",l=>{l.stopPropagation(),n.onInfo(e.id)});const b=document.createElement("button");b.className="action-btn open",b.textContent="Open",b.addEventListener("click",l=>{l.stopPropagation(),n.onOpen(e)}),f.appendChild(b),f.appendChild(w),f.appendChild(N),f.appendChild(C);const R=document.createElement("div");return R.className="card-metadata hidden",o.addEventListener("click",()=>{n.onOpen(e)}),o.appendChild(i),o.appendChild(I),o.appendChild(h),o.appendChild(g),o.appendChild(f),o.appendChild(R),o}function Q(e,t,n){e.querySelector(".confirm-overlay")?.remove();const o=document.createElement("div");o.className="confirm-overlay";const a=document.createElement("span");a.className="confirm-msg",a.textContent="Stop this session?";const s=document.createElement("button");s.className="confirm-btn yes",s.textContent="Yes",s.addEventListener("click",c=>{c.stopPropagation(),n(t),o.remove()});const i=document.createElement("button");i.className="confirm-btn no",i.textContent="No",i.addEventListener("click",c=>{c.stopPropagation(),o.remove()}),o.addEventListener("click",c=>c.stopPropagation()),o.appendChild(a),o.appendChild(s),o.appendChild(i),e.appendChild(o)}function Z(e,t,n){const o=e.querySelector(".agent-name");if(!o)return;const a=o.textContent||"",s=document.createElement("input");s.className="rename-input",s.type="text",s.value=a;const i=()=>{const c=s.value.trim();c&&c!==a&&n(t,c),o.textContent=c||a,o.style.display="",s.remove()};s.addEventListener("keydown",c=>{c.stopPropagation(),c.key==="Enter"?i():c.key==="Escape"&&(o.textContent=a,o.style.display="",s.remove())}),s.addEventListener("blur",i),s.addEventListener("click",c=>c.stopPropagation()),o.style.display="none",o.parentElement?.insertBefore(s,o.nextSibling),s.focus(),s.select()}function G(e,t){const n=e.querySelector(".agent-dot"),o=e.querySelector(".status-badge"),a=e.querySelector(".card-uptime"),s=e.querySelector(".agent-name");n&&(n.className=`agent-dot ${t.status}`),o&&(o.className=`status-badge ${t.status}`,o.textContent=t.status),a&&(a.textContent=B(Date.now()-t.connectedAt)),s&&!s.style.display&&(s.textContent=t.name||t.agent);const i=t.status==="attention";o&&(o.style.display=i?"none":"");const c=e.querySelector(".attention-badge");c&&(c.style.display=i?"":"none"),t.status==="attention"?e.classList.add("attention"):e.classList.remove("attention")}function ee(e,t){const n=e.querySelector(".card-preview-text");n&&(t.length>0?(n.textContent=t.join(`
2
+ `),n.classList.remove("empty")):(n.textContent="Waiting for output...",n.classList.add("empty")))}function te(e,t){const n=e.querySelector(".card-metadata");if(!n)return;for(;n.firstChild;)n.removeChild(n.firstChild);const o=[["PID",String(t.pid)],["Agent",t.agent],["Port",String(t.port)],["Directory",t.cwd],["Memory",X(t.memoryKB)],["Uptime",B(t.uptimeMs)]];for(const[a,s]of o){const i=document.createElement("div");i.className="meta-row";const c=document.createElement("span");c.className="meta-label",c.textContent=a;const p=document.createElement("span");p.className="meta-value",p.textContent=s,i.appendChild(c),i.appendChild(p),n.appendChild(i)}n.classList.toggle("hidden")}let r=null,q=!1;function ne(){if(!q){q=!0;try{r=new AudioContext,r.state==="suspended"&&r.resume()}catch{}}}function U(){if(!r)return;r.state==="suspended"&&r.resume();const e=r.currentTime,t=r.createOscillator(),n=r.createGain();t.frequency.value=587.33,t.type="sine",n.gain.setValueAtTime(.3,e),n.gain.exponentialRampToValueAtTime(.01,e+.3),t.connect(n),n.connect(r.destination),t.start(e),t.stop(e+.3);const o=r.createOscillator(),a=r.createGain();o.frequency.value=880,o.type="sine",a.gain.setValueAtTime(.3,e+.15),a.gain.exponentialRampToValueAtTime(.01,e+.45),o.connect(a),a.connect(r.destination),o.start(e+.15),o.stop(e+.45)}const D=120*1e3,u=new Map;function _(e){if(u.has(e))return;U();const t=setTimeout(function n(){U();const o=u.get(e);o&&(o.timerId=setTimeout(n,D))},D);u.set(e,{timerId:t})}function A(e){const t=u.get(e);t&&(clearTimeout(t.timerId),u.delete(e))}function V(){for(const e of u.values())clearTimeout(e.timerId);u.clear()}const oe=new URLSearchParams(window.location.search),J=oe.get("token");if(!J)throw document.body.textContent="Missing authentication token.",new Error("No token in URL");const ae=window.location.protocol==="https:"?"wss:":"ws:",se=`${ae}//${window.location.host}?token=${J}`,Y=window.location.hostname,y=new Map,m=new Map,ce=document.getElementById("session-list"),O=document.getElementById("empty-state"),ie=document.getElementById("session-count"),W=document.getElementById("status-dot");let M=null;function x(){ne(),document.removeEventListener("click",x),document.removeEventListener("touchstart",x)}document.addEventListener("click",x);document.addEventListener("touchstart",x);function S(e){d&&d.readyState===WebSocket.OPEN&&d.send(JSON.stringify(e))}const re={onOpen(e){const t=y.get(e.id)||e;t.status==="attention"&&(S({type:"clear-attention",sessionId:t.id}),A(t.id));const n=window.location.href,o=`http://${Y}:${t.port}?token=${t.token}&hub=${encodeURIComponent(n)}`;window.open(o,"_blank")},onStop(e){S({type:"stop-session",sessionId:e})},onRename(e,t){S({type:"rename-session",sessionId:e,name:t})},onInfo(e){S({type:"get-metadata",sessionId:e})}};function j(){const e=y.size;ie.textContent=`${e} session${e!==1?"s":""}`,e===0?O.style.display="flex":O.style.display="none"}function F(e){y.set(e.id,e);const t=H(e,Y,re);m.set(e.id,t),ce.insertBefore(t,O),j()}function K(e){y.delete(e);const t=m.get(e);t&&(t.remove(),m.delete(e)),j()}function de(e){y.set(e.id,e);const t=m.get(e.id);t&&G(t,e)}function le(){for(const[e,t]of y){const n=m.get(e);n&&G(n,t)}}let d=null,L=0;const me=1e4;function T(){d=new WebSocket(se),d.onopen=()=>{W.className="connected",L=0,M&&clearInterval(M),M=setInterval(le,1e4)},d.onmessage=e=>{try{const t=JSON.parse(e.data);switch(t.type){case"sessions":{V();for(const n of m.keys())K(n);for(const n of t.sessions)F(n),n.status==="attention"&&_(n.id);break}case"session-added":{F(t.session);break}case"session-removed":{const n=t.sessionId;A(n),K(n);break}case"session-updated":{const n=t.session;de(n),n.status==="attention"?_(n.id):A(n.id);break}case"preview":{const n=m.get(t.sessionId);n&&ee(n,t.lines);break}case"metadata":{const n=m.get(t.sessionId);n&&te(n,t.metadata);break}case"operation-error":{console.warn(`Operation "${t.operation}" failed for session ${t.sessionId}: ${t.error}`);break}}}catch{}},d.onclose=()=>{W.className="reconnecting",V(),ue()},d.onerror=()=>{d?.close()}}function ue(){const e=Math.min(1e3*Math.pow(1.5,L),me);L++,setTimeout(T,e)}document.addEventListener("visibilitychange",()=>{document.visibilityState==="visible"&&d?.readyState!==WebSocket.OPEN&&(L=0,T())});T();
@@ -0,0 +1 @@
1
+ *{margin:0;padding:0;box-sizing:border-box}html,body{height:100%;width:100%;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#e0e0e0;overflow-x:hidden;-webkit-overflow-scrolling:touch}#header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;position:sticky;top:0;z-index:10}.header-left{display:flex;align-items:center;gap:10px}.logo{font-size:16px;font-weight:700;color:#e94560;letter-spacing:-.5px}#status-dot{width:8px;height:8px;border-radius:50%;background:#e74c3c;transition:background .3s ease}#status-dot.connected{background:#2ecc71}#status-dot.reconnecting{background:#f39c12;animation:pulse 1s infinite}#session-count{font-size:13px;color:#a0a0b0}#session-list{padding:12px 12px 80px;display:flex;flex-direction:column;gap:12px}#empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;color:#606080;text-align:center;gap:8px}#empty-state .empty-icon{font-size:48px;margin-bottom:8px;opacity:.5}#empty-state p{font-size:16px}#empty-state .empty-hint{font-size:13px;color:#505070}#empty-state code{background:#252540;padding:2px 8px;border-radius:4px;font-family:Cascadia Code,Fira Code,JetBrains Mono,monospace;font-size:12px;color:#e94560}.session-card{background:#16213e;border:1px solid #0f3460;border-radius:12px;padding:14px 16px;cursor:pointer;transition:transform .15s ease,border-color .2s ease;-webkit-tap-highlight-color:transparent}.session-card:active{transform:scale(.98)}.session-card:hover{border-color:#e94560}.session-card.attention{border-color:#e94560;animation:attention-glow 2s ease-in-out infinite}@keyframes attention-glow{0%,to{box-shadow:0 0 #e9456000}50%{box-shadow:0 0 12px 2px #e945604d}}.card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.card-agent{display:flex;align-items:center;gap:8px}.agent-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}.agent-dot.active{background:#2ecc71}.agent-dot.idle{background:#f39c12}.agent-dot.attention{background:#e94560;animation:pulse 1s infinite}.agent-name{font-size:15px;font-weight:600;color:#f0f0f0}.card-uptime{font-size:12px;color:#707090;font-variant-numeric:tabular-nums}.card-cwd{font-size:12px;color:#808098;margin-bottom:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Cascadia Code,Fira Code,JetBrains Mono,monospace}.card-preview{background:#112;border-radius:6px;padding:8px 10px;margin-bottom:10px;overflow:hidden;min-height:24px;max-height:90px}.card-preview-text{font-family:Cascadia Code,Fira Code,JetBrains Mono,monospace;font-size:11px;line-height:1.4;color:#a0a0b0;white-space:pre;overflow:hidden;text-overflow:ellipsis;margin:0}.card-preview-text.empty{color:#505070;font-style:italic}.card-status{display:flex;align-items:center;gap:6px;font-size:12px;color:#a0a0b0}.status-badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.5px}.status-badge.active{background:#2ecc7126;color:#2ecc71}.status-badge.idle{background:#f39c1226;color:#f39c12}.status-badge.attention{background:#e9456026;color:#e94560}.attention-badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;background:#e9456033;color:#e94560;animation:attention-pulse 2s ease-in-out infinite}@keyframes attention-pulse{0%,to{opacity:1}50%{opacity:.6}}.card-actions{display:flex;gap:6px;margin-top:10px;padding-top:10px;border-top:1px solid #0f3460}.action-btn{flex:1;padding:6px 0;border:1px solid #2a2a44;border-radius:6px;background:#1e1e36;color:#a0a0b0;font-size:12px;font-weight:500;font-family:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background .15s,color .15s}.action-btn:active{background:#2a2a44}.action-btn.open{color:#2ecc71;border-color:#2ecc714d}.action-btn.stop{color:#e94560;border-color:#e945604d}.action-btn.stop:active{background:#e9456026}.confirm-overlay{display:flex;align-items:center;justify-content:center;gap:10px;padding:10px;margin-top:8px;background:#2d1810;border:1px solid #e94560;border-radius:8px;animation:fadeIn .15s ease}.confirm-msg{font-size:13px;color:#f0f0f0;flex:1}.confirm-btn{padding:5px 14px;border:none;border-radius:5px;font-size:12px;font-weight:600;font-family:inherit;cursor:pointer}.confirm-btn.yes{background:#e94560;color:#fff}.confirm-btn.no{background:#2a2a44;color:#a0a0b0}.rename-input{background:#112;border:1px solid #e94560;border-radius:4px;color:#f0f0f0;font-size:15px;font-weight:600;font-family:inherit;padding:2px 6px;outline:none;width:120px}.card-metadata{margin-top:8px;padding:8px 10px;background:#112;border-radius:6px;animation:fadeIn .2s ease}.card-metadata.hidden{display:none}.meta-row{display:flex;justify-content:space-between;padding:3px 0;font-size:11px}.meta-label{color:#707090;font-weight:500}.meta-value{color:#c0c0d0;font-family:Cascadia Code,Fira Code,JetBrains Mono,monospace;font-size:11px;text-align:right;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}@media(min-width:768px){#session-list{max-width:600px;margin:0 auto}}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}@keyframes fadeIn{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.session-card{animation:fadeIn .3s ease}#reconnect-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:#1a1a2ef2;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:100;color:#e0e0e0;font-size:16px;gap:12px}#reconnect-overlay .spinner{width:32px;height:32px;border:3px solid #0f3460;border-top-color:#e94560;border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
@@ -0,0 +1,31 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
6
+ <meta name="apple-mobile-web-app-capable" content="yes" />
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
8
+ <meta name="theme-color" content="#1a1a2e" />
9
+ <title>itwillsync Dashboard</title>
10
+ <script type="module" crossorigin src="/assets/index-DgUZUPW_.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-Erqx_a0N.css">
12
+ </head>
13
+ <body>
14
+ <header id="header">
15
+ <div class="header-left">
16
+ <span class="logo">itwillsync</span>
17
+ <span id="status-dot"></span>
18
+ </div>
19
+ <span id="session-count">0 sessions</span>
20
+ </header>
21
+
22
+ <main id="session-list">
23
+ <div id="empty-state">
24
+ <div class="empty-icon">&#x1F4BB;</div>
25
+ <p>No active sessions</p>
26
+ <p class="empty-hint">Start an agent with <code>itwillsync -- claude</code></p>
27
+ </div>
28
+ </main>
29
+
30
+ </body>
31
+ </html>
package/dist/index.js CHANGED
@@ -2766,7 +2766,7 @@ var require_websocket = __commonJS({
2766
2766
  }
2767
2767
  const defaultPort = isSecure ? 443 : 80;
2768
2768
  const key = randomBytes2(16).toString("base64");
2769
- const request = isSecure ? https.request : http.request;
2769
+ const request2 = isSecure ? https.request : http.request;
2770
2770
  const protocolSet = /* @__PURE__ */ new Set();
2771
2771
  let perMessageDeflate;
2772
2772
  opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect);
@@ -2843,12 +2843,12 @@ var require_websocket = __commonJS({
2843
2843
  if (opts.auth && !options.headers.authorization) {
2844
2844
  options.headers.authorization = "Basic " + Buffer.from(opts.auth).toString("base64");
2845
2845
  }
2846
- req = websocket._req = request(opts);
2846
+ req = websocket._req = request2(opts);
2847
2847
  if (websocket._redirects) {
2848
2848
  websocket.emit("redirect", websocket.url, req);
2849
2849
  }
2850
2850
  } else {
2851
- req = websocket._req = request(opts);
2851
+ req = websocket._req = request2(opts);
2852
2852
  }
2853
2853
  if (opts.timeout) {
2854
2854
  req.on("timeout", () => {
@@ -3933,6 +3933,7 @@ function createSyncServer(options) {
3933
3933
  const clients = /* @__PURE__ */ new Set();
3934
3934
  const aliveMap = /* @__PURE__ */ new WeakMap();
3935
3935
  let scrollbackBuffer = "";
3936
+ let seq = 0;
3936
3937
  const httpServer = createServer2(async (req, res) => {
3937
3938
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
3938
3939
  let pathname = url.pathname;
@@ -3969,7 +3970,7 @@ function createSyncServer(options) {
3969
3970
  clients.add(ws);
3970
3971
  aliveMap.set(ws, true);
3971
3972
  if (scrollbackBuffer.length > 0) {
3972
- ws.send(scrollbackBuffer);
3973
+ ws.send(JSON.stringify({ type: "data", data: scrollbackBuffer, seq }));
3973
3974
  }
3974
3975
  ws.on("pong", () => {
3975
3976
  aliveMap.set(ws, true);
@@ -3981,6 +3982,12 @@ function createSyncServer(options) {
3981
3982
  ptyManager.write(message.data);
3982
3983
  } else if (message.type === "resize" && typeof message.cols === "number" && typeof message.rows === "number") {
3983
3984
  ptyManager.resize(message.cols, message.rows);
3985
+ } else if (message.type === "resume" && typeof message.lastSeq === "number") {
3986
+ const missed = seq - message.lastSeq;
3987
+ if (missed > 0 && scrollbackBuffer.length > 0) {
3988
+ const delta = missed <= scrollbackBuffer.length ? scrollbackBuffer.slice(-missed) : scrollbackBuffer;
3989
+ ws.send(JSON.stringify({ type: "data", data: delta, seq }));
3990
+ }
3984
3991
  }
3985
3992
  } catch {
3986
3993
  }
@@ -3993,13 +4000,15 @@ function createSyncServer(options) {
3993
4000
  });
3994
4001
  });
3995
4002
  ptyManager.onData((data) => {
4003
+ seq += data.length;
3996
4004
  scrollbackBuffer += data;
3997
4005
  if (scrollbackBuffer.length > SCROLLBACK_BUFFER_SIZE) {
3998
4006
  scrollbackBuffer = scrollbackBuffer.slice(-SCROLLBACK_BUFFER_SIZE);
3999
4007
  }
4008
+ const msg = JSON.stringify({ type: "data", data, seq });
4000
4009
  for (const client of clients) {
4001
4010
  if (client.readyState === client.OPEN) {
4002
- client.send(data);
4011
+ client.send(msg);
4003
4012
  }
4004
4013
  }
4005
4014
  });
@@ -4140,8 +4149,233 @@ async function runSetupWizard() {
4140
4149
  return config;
4141
4150
  }
4142
4151
 
4152
+ // src/hub-client.ts
4153
+ import { spawn as spawn2 } from "child_process";
4154
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
4155
+ import { homedir as homedir2 } from "os";
4156
+ import { join as join4, dirname as dirname2 } from "path";
4157
+ import { fileURLToPath as fileURLToPath2 } from "url";
4158
+ import { request } from "http";
4159
+ var HUB_INTERNAL_PORT = 7963;
4160
+ var HUB_EXTERNAL_PORT = 7962;
4161
+ var SESSION_PORT_START = 7964;
4162
+ function getHubDir() {
4163
+ return process.env.ITWILLSYNC_CONFIG_DIR || join4(homedir2(), ".itwillsync");
4164
+ }
4165
+ function getHubConfigPath() {
4166
+ return join4(getHubDir(), "hub.json");
4167
+ }
4168
+ async function discoverHub() {
4169
+ return new Promise((resolve) => {
4170
+ const req = request(
4171
+ {
4172
+ hostname: "127.0.0.1",
4173
+ port: HUB_INTERNAL_PORT,
4174
+ path: "/api/health",
4175
+ method: "GET",
4176
+ timeout: 2e3
4177
+ },
4178
+ (res) => {
4179
+ let data = "";
4180
+ res.on("data", (chunk) => {
4181
+ data += chunk;
4182
+ });
4183
+ res.on("end", () => {
4184
+ try {
4185
+ const json = JSON.parse(data);
4186
+ resolve(json.status === "ok");
4187
+ } catch {
4188
+ resolve(false);
4189
+ }
4190
+ });
4191
+ }
4192
+ );
4193
+ req.on("error", () => resolve(false));
4194
+ req.on("timeout", () => {
4195
+ req.destroy();
4196
+ resolve(false);
4197
+ });
4198
+ req.end();
4199
+ });
4200
+ }
4201
+ async function spawnHub() {
4202
+ const __dirname = dirname2(fileURLToPath2(import.meta.url));
4203
+ const hubPath = join4(__dirname, "hub", "daemon.js");
4204
+ return new Promise((resolve, reject) => {
4205
+ const child = spawn2("node", [hubPath], {
4206
+ detached: true,
4207
+ stdio: ["ignore", "pipe", "ignore"],
4208
+ env: {
4209
+ ...process.env,
4210
+ // Pass config dir to hub
4211
+ ITWILLSYNC_CONFIG_DIR: process.env.ITWILLSYNC_CONFIG_DIR || ""
4212
+ }
4213
+ });
4214
+ child.unref();
4215
+ let output = "";
4216
+ const timeout = setTimeout(() => {
4217
+ reject(new Error("Hub daemon startup timed out"));
4218
+ }, 1e4);
4219
+ child.stdout?.on("data", (data) => {
4220
+ output += data.toString();
4221
+ if (output.includes("hub:ready:")) {
4222
+ clearTimeout(timeout);
4223
+ child.stdout?.destroy();
4224
+ resolve();
4225
+ }
4226
+ });
4227
+ child.on("error", (err) => {
4228
+ clearTimeout(timeout);
4229
+ reject(new Error(`Failed to spawn hub daemon: ${err.message}`));
4230
+ });
4231
+ child.on("exit", (code) => {
4232
+ clearTimeout(timeout);
4233
+ if (code !== null && code !== 0) {
4234
+ reject(new Error(`Hub daemon exited with code ${code}`));
4235
+ }
4236
+ });
4237
+ });
4238
+ }
4239
+ function getHubConfig() {
4240
+ const configPath = getHubConfigPath();
4241
+ if (!existsSync2(configPath)) {
4242
+ return null;
4243
+ }
4244
+ try {
4245
+ const raw = readFileSync2(configPath, "utf-8");
4246
+ return JSON.parse(raw);
4247
+ } catch {
4248
+ return null;
4249
+ }
4250
+ }
4251
+ async function registerSession(registration) {
4252
+ const body = JSON.stringify(registration);
4253
+ return new Promise((resolve, reject) => {
4254
+ const req = request(
4255
+ {
4256
+ hostname: "127.0.0.1",
4257
+ port: HUB_INTERNAL_PORT,
4258
+ path: "/api/sessions",
4259
+ method: "POST",
4260
+ headers: {
4261
+ "Content-Type": "application/json",
4262
+ "Content-Length": Buffer.byteLength(body)
4263
+ },
4264
+ timeout: 5e3
4265
+ },
4266
+ (res) => {
4267
+ let data = "";
4268
+ res.on("data", (chunk) => {
4269
+ data += chunk;
4270
+ });
4271
+ res.on("end", () => {
4272
+ try {
4273
+ const json = JSON.parse(data);
4274
+ if (res.statusCode === 201 && json.session) {
4275
+ resolve(json.session);
4276
+ } else {
4277
+ reject(new Error(`Registration failed: ${json.error || "Unknown error"}`));
4278
+ }
4279
+ } catch {
4280
+ reject(new Error("Invalid response from hub"));
4281
+ }
4282
+ });
4283
+ }
4284
+ );
4285
+ req.on("error", (err) => reject(new Error(`Failed to register with hub: ${err.message}`)));
4286
+ req.on("timeout", () => {
4287
+ req.destroy();
4288
+ reject(new Error("Registration request timed out"));
4289
+ });
4290
+ req.end(body);
4291
+ });
4292
+ }
4293
+ async function unregisterSession(sessionId) {
4294
+ return new Promise((resolve) => {
4295
+ const req = request(
4296
+ {
4297
+ hostname: "127.0.0.1",
4298
+ port: HUB_INTERNAL_PORT,
4299
+ path: `/api/sessions/${sessionId}`,
4300
+ method: "DELETE",
4301
+ timeout: 3e3
4302
+ },
4303
+ () => resolve()
4304
+ );
4305
+ req.on("error", () => resolve());
4306
+ req.on("timeout", () => {
4307
+ req.destroy();
4308
+ resolve();
4309
+ });
4310
+ req.end();
4311
+ });
4312
+ }
4313
+ async function listSessions() {
4314
+ return new Promise((resolve) => {
4315
+ const req = request(
4316
+ {
4317
+ hostname: "127.0.0.1",
4318
+ port: HUB_INTERNAL_PORT,
4319
+ path: "/api/sessions",
4320
+ method: "GET",
4321
+ timeout: 3e3
4322
+ },
4323
+ (res) => {
4324
+ let data = "";
4325
+ res.on("data", (chunk) => {
4326
+ data += chunk;
4327
+ });
4328
+ res.on("end", () => {
4329
+ try {
4330
+ const json = JSON.parse(data);
4331
+ resolve(json.sessions || []);
4332
+ } catch {
4333
+ resolve([]);
4334
+ }
4335
+ });
4336
+ }
4337
+ );
4338
+ req.on("error", () => resolve([]));
4339
+ req.on("timeout", () => {
4340
+ req.destroy();
4341
+ resolve([]);
4342
+ });
4343
+ req.end();
4344
+ });
4345
+ }
4346
+ function stopHub() {
4347
+ const config = getHubConfig();
4348
+ if (!config) return false;
4349
+ try {
4350
+ process.kill(config.pid, "SIGTERM");
4351
+ return true;
4352
+ } catch {
4353
+ return false;
4354
+ }
4355
+ }
4356
+ async function sendHeartbeat(sessionId) {
4357
+ return new Promise((resolve) => {
4358
+ const req = request(
4359
+ {
4360
+ hostname: "127.0.0.1",
4361
+ port: HUB_INTERNAL_PORT,
4362
+ path: `/api/sessions/${sessionId}/heartbeat`,
4363
+ method: "PUT",
4364
+ timeout: 2e3
4365
+ },
4366
+ () => resolve()
4367
+ );
4368
+ req.on("error", () => resolve());
4369
+ req.on("timeout", () => {
4370
+ req.destroy();
4371
+ resolve();
4372
+ });
4373
+ req.end();
4374
+ });
4375
+ }
4376
+
4143
4377
  // src/cli-options.ts
4144
- var DEFAULT_PORT = 3456;
4378
+ var DEFAULT_PORT = SESSION_PORT_START;
4145
4379
  function parseArgs(argv) {
4146
4380
  const options = {
4147
4381
  port: DEFAULT_PORT,
@@ -4150,13 +4384,24 @@ function parseArgs(argv) {
4150
4384
  command: [],
4151
4385
  subcommand: null,
4152
4386
  tailscale: false,
4153
- local: false
4387
+ local: false,
4388
+ hubInfo: false,
4389
+ hubStop: false,
4390
+ hubStatus: false
4154
4391
  };
4155
4392
  const args = argv.slice(2);
4156
4393
  if (args.length > 0 && args[0] === "setup") {
4157
4394
  options.subcommand = "setup";
4158
4395
  return options;
4159
4396
  }
4397
+ if (args.length > 0 && args[0] === "hub") {
4398
+ const hubAction = args[1] || "info";
4399
+ if (hubAction === "info") options.hubInfo = true;
4400
+ else if (hubAction === "stop") options.hubStop = true;
4401
+ else if (hubAction === "status") options.hubStatus = true;
4402
+ else options.hubInfo = true;
4403
+ return options;
4404
+ }
4160
4405
  let i = 0;
4161
4406
  while (i < args.length) {
4162
4407
  const arg = args[i];
@@ -4178,6 +4423,15 @@ function parseArgs(argv) {
4178
4423
  } else if (arg === "--no-qr") {
4179
4424
  options.noQr = true;
4180
4425
  i++;
4426
+ } else if (arg === "--hub-info") {
4427
+ options.hubInfo = true;
4428
+ i++;
4429
+ } else if (arg === "--hub-stop") {
4430
+ options.hubStop = true;
4431
+ i++;
4432
+ } else if (arg === "--hub-status") {
4433
+ options.hubStatus = true;
4434
+ i++;
4181
4435
  } else if (arg === "--help" || arg === "-h") {
4182
4436
  printHelp();
4183
4437
  process.exit(0);
@@ -4199,6 +4453,7 @@ Usage:
4199
4453
  itwillsync [options] -- <command> [args...]
4200
4454
  itwillsync [options] <command> [args...]
4201
4455
  itwillsync setup
4456
+ itwillsync hub [info|stop|status]
4202
4457
 
4203
4458
  Examples:
4204
4459
  itwillsync -- claude
@@ -4207,9 +4462,14 @@ Examples:
4207
4462
  itwillsync --port 8080 -- claude
4208
4463
  itwillsync --tailscale -- claude
4209
4464
  itwillsync setup
4465
+ itwillsync hub info
4466
+ itwillsync hub stop
4210
4467
 
4211
4468
  Commands:
4212
4469
  setup Run the setup wizard (configure networking mode)
4470
+ hub info Show dashboard URL, QR code, and hub status
4471
+ hub stop Stop the hub daemon and all sessions
4472
+ hub status List all active sessions
4213
4473
 
4214
4474
  Options:
4215
4475
  --port <number> Port to listen on (default: ${DEFAULT_PORT})
@@ -4219,24 +4479,29 @@ Options:
4219
4479
  --no-qr Don't display QR code
4220
4480
  -h, --help Show this help
4221
4481
  -v, --version Show version
4482
+
4483
+ Hub Management:
4484
+ --hub-info Show dashboard URL, QR code, and hub status
4485
+ --hub-stop Stop the hub daemon and all sessions
4486
+ --hub-status List all active sessions
4222
4487
  `);
4223
4488
  }
4224
4489
 
4225
4490
  // src/index.ts
4226
- import { fileURLToPath as fileURLToPath2 } from "url";
4227
- import { join as join4, dirname as dirname2 } from "path";
4228
- import { spawn as spawn2 } from "child_process";
4491
+ import { fileURLToPath as fileURLToPath3 } from "url";
4492
+ import { join as join5, dirname as dirname3 } from "path";
4493
+ import { spawn as spawn3 } from "child_process";
4229
4494
  function preventSleep() {
4230
4495
  try {
4231
4496
  if (process.platform === "darwin") {
4232
- const child = spawn2("caffeinate", ["-i", "-w", String(process.pid)], {
4497
+ const child = spawn3("caffeinate", ["-i", "-w", String(process.pid)], {
4233
4498
  stdio: "ignore",
4234
4499
  detached: true
4235
4500
  });
4236
4501
  child.unref();
4237
4502
  return child;
4238
4503
  } else if (process.platform === "linux") {
4239
- return spawn2("systemd-inhibit", [
4504
+ return spawn3("systemd-inhibit", [
4240
4505
  "--what=idle",
4241
4506
  "--who=itwillsync",
4242
4507
  "--why=Terminal sync session active",
@@ -4248,12 +4513,96 @@ function preventSleep() {
4248
4513
  }
4249
4514
  return null;
4250
4515
  }
4516
+ async function ensureHub() {
4517
+ const hubRunning = await discoverHub();
4518
+ if (hubRunning) {
4519
+ return false;
4520
+ }
4521
+ try {
4522
+ await spawnHub();
4523
+ await new Promise((resolve) => setTimeout(resolve, 500));
4524
+ return true;
4525
+ } catch (err) {
4526
+ console.warn(`
4527
+ Warning: Could not start hub daemon: ${err.message}`);
4528
+ console.warn(" Running in standalone mode (no dashboard).\n");
4529
+ return true;
4530
+ }
4531
+ }
4532
+ async function handleHubCommand(options) {
4533
+ const hubConfig = getHubConfig();
4534
+ const hubRunning = await discoverHub();
4535
+ if (options.hubStop) {
4536
+ if (!hubRunning || !hubConfig) {
4537
+ console.log("\n No hub daemon is running.\n");
4538
+ return;
4539
+ }
4540
+ const stopped = stopHub();
4541
+ if (stopped) {
4542
+ console.log("\n Hub daemon stopped.\n");
4543
+ } else {
4544
+ console.log("\n Failed to stop hub daemon.\n");
4545
+ }
4546
+ return;
4547
+ }
4548
+ if (options.hubStatus) {
4549
+ if (!hubRunning) {
4550
+ console.log("\n No hub daemon is running.\n");
4551
+ return;
4552
+ }
4553
+ const sessions = await listSessions();
4554
+ console.log(`
4555
+ Hub is running. ${sessions.length} active session(s).
4556
+ `);
4557
+ if (sessions.length > 0) {
4558
+ for (const s of sessions) {
4559
+ const uptime = Math.floor((Date.now() - s.connectedAt) / 6e4);
4560
+ console.log(` ${s.name || s.agent} (${s.status}, ${uptime}m, port ${s.port})`);
4561
+ }
4562
+ console.log("");
4563
+ }
4564
+ return;
4565
+ }
4566
+ if (options.hubInfo) {
4567
+ if (!hubRunning || !hubConfig) {
4568
+ console.log("\n No hub daemon is running.");
4569
+ console.log(" Start a session with: itwillsync -- <agent>\n");
4570
+ return;
4571
+ }
4572
+ let networkingMode = "local";
4573
+ if (options.tailscale) {
4574
+ networkingMode = "tailscale";
4575
+ } else if (options.local) {
4576
+ networkingMode = "local";
4577
+ } else if (configExists()) {
4578
+ networkingMode = loadConfig().networkingMode;
4579
+ }
4580
+ const ip = await resolveSessionIP(networkingMode, false);
4581
+ const dashboardUrl = `http://${ip}:${HUB_EXTERNAL_PORT}?token=${hubConfig.masterToken}`;
4582
+ displayQR(dashboardUrl);
4583
+ console.log(` Dashboard: ${dashboardUrl}`);
4584
+ const sessions = await listSessions();
4585
+ console.log(` Sessions: ${sessions.length} active`);
4586
+ if (sessions.length > 0) {
4587
+ for (const s of sessions) {
4588
+ const uptime = Math.floor((Date.now() - s.connectedAt) / 6e4);
4589
+ console.log(` ${s.name || s.agent} (${s.status}, ${uptime}m)`);
4590
+ }
4591
+ }
4592
+ console.log("");
4593
+ return;
4594
+ }
4595
+ }
4251
4596
  async function main() {
4252
4597
  const options = parseArgs(process.argv);
4253
4598
  if (options.subcommand === "setup") {
4254
4599
  await runSetupWizard();
4255
4600
  return;
4256
4601
  }
4602
+ if (options.hubInfo || options.hubStop || options.hubStatus) {
4603
+ await handleHubCommand(options);
4604
+ return;
4605
+ }
4257
4606
  if (options.tailscale && options.local) {
4258
4607
  console.error("Error: Cannot use both --tailscale and --local.\n");
4259
4608
  process.exit(1);
@@ -4276,13 +4625,14 @@ async function main() {
4276
4625
  networkingMode = config.networkingMode;
4277
4626
  }
4278
4627
  const [cmd, ...cmdArgs] = options.command;
4628
+ const isFirstSession = await ensureHub();
4629
+ const hubConfig = getHubConfig();
4279
4630
  const token = generateToken();
4280
4631
  const port = await findAvailablePort(options.port);
4281
4632
  const host = options.localhost ? "127.0.0.1" : "0.0.0.0";
4282
4633
  const ip = await resolveSessionIP(networkingMode, options.localhost);
4283
- const url = `http://${ip}:${port}?token=${token}`;
4284
- const __dirname = dirname2(fileURLToPath2(import.meta.url));
4285
- const webClientPath = join4(__dirname, "web-client");
4634
+ const __dirname = dirname3(fileURLToPath3(import.meta.url));
4635
+ const webClientPath = join5(__dirname, "web-client");
4286
4636
  const ptyManager = new PtyManager(cmd, cmdArgs);
4287
4637
  const server = createSyncServer({
4288
4638
  ptyManager,
@@ -4291,12 +4641,42 @@ async function main() {
4291
4641
  host,
4292
4642
  port
4293
4643
  });
4294
- if (!options.noQr) {
4295
- displayQR(url);
4296
- } else {
4644
+ let registeredSession = null;
4645
+ let heartbeatInterval = null;
4646
+ if (hubConfig) {
4647
+ try {
4648
+ registeredSession = await registerSession({
4649
+ name: cmd,
4650
+ port,
4651
+ token,
4652
+ agent: cmd,
4653
+ cwd: process.cwd(),
4654
+ pid: ptyManager.pid
4655
+ });
4656
+ heartbeatInterval = setInterval(() => {
4657
+ if (registeredSession) {
4658
+ sendHeartbeat(registeredSession.id);
4659
+ }
4660
+ }, 1e4);
4661
+ } catch (err) {
4662
+ console.warn(` Warning: Failed to register with hub: ${err.message}`);
4663
+ }
4664
+ }
4665
+ const dashboardUrl = hubConfig ? `http://${ip}:${HUB_EXTERNAL_PORT}?token=${hubConfig.masterToken}` : null;
4666
+ if (isFirstSession && dashboardUrl && !options.noQr) {
4667
+ displayQR(dashboardUrl);
4668
+ console.log(` Dashboard: ${dashboardUrl}`);
4669
+ } else if (isFirstSession && !options.noQr) {
4670
+ const directUrl = `http://${ip}:${port}?token=${token}`;
4671
+ displayQR(directUrl);
4672
+ } else if (dashboardUrl) {
4297
4673
  console.log(`
4298
- Connect at: ${url}
4299
- `);
4674
+ Session "${cmd}" registered with hub.`);
4675
+ console.log(` Dashboard: ${dashboardUrl}`);
4676
+ console.log("");
4677
+ } else if (!options.noQr) {
4678
+ const directUrl = `http://${ip}:${port}?token=${token}`;
4679
+ displayQR(directUrl);
4300
4680
  }
4301
4681
  const sleepGuard = preventSleep();
4302
4682
  console.log(` Server listening on ${host}:${port}`);
@@ -4322,26 +4702,32 @@ async function main() {
4322
4702
  }
4323
4703
  process.stdout.on("resize", handleResize);
4324
4704
  handleResize();
4325
- function cleanup() {
4705
+ async function cleanup() {
4326
4706
  if (process.stdin.isTTY) {
4327
4707
  process.stdin.setRawMode(false);
4328
4708
  }
4709
+ if (heartbeatInterval) {
4710
+ clearInterval(heartbeatInterval);
4711
+ }
4712
+ if (registeredSession) {
4713
+ await unregisterSession(registeredSession.id);
4714
+ }
4329
4715
  sleepGuard?.kill();
4330
4716
  server.close();
4331
4717
  ptyManager.kill();
4332
4718
  }
4333
- ptyManager.onExit((exitCode) => {
4719
+ ptyManager.onExit(async (exitCode) => {
4334
4720
  console.log(`
4335
4721
  Agent exited with code ${exitCode}`);
4336
- cleanup();
4722
+ await cleanup();
4337
4723
  process.exit(exitCode);
4338
4724
  });
4339
- process.on("SIGINT", () => {
4340
- cleanup();
4725
+ process.on("SIGINT", async () => {
4726
+ await cleanup();
4341
4727
  process.exit(0);
4342
4728
  });
4343
- process.on("SIGTERM", () => {
4344
- cleanup();
4729
+ process.on("SIGTERM", async () => {
4730
+ await cleanup();
4345
4731
  process.exit(0);
4346
4732
  });
4347
4733
  }