airpocket 0.1.0 → 0.1.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ Every change people using Airpocket would notice, newest first. The form is
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.1.1 - 2026-11-06
8
+
9
+ A fix for typing from the phone. Reported from a real install: the
10
+ conversation kept updating on the phone, and a reply answered "Something went
11
+ wrong on the Mac."
12
+
13
+ ### Fixed
14
+
15
+ - **A terminal that fails as the keys land now says which terminal, and what
16
+ its own program said**, instead of the one message nobody can act on. Reading
17
+ a session and typing into it are different paths: the conversation comes from
18
+ hook events and session files and never touches a terminal, so a failure was
19
+ always in the last step, and that step had nothing around it. Sending a
20
+ message, pressing a key and running a session command are all guarded now,
21
+ and point at `airpocket doctor`.
22
+ - **A session in a Superset terminal is read-only up front when Superset isn't
23
+ installed**, rather than looking writable and failing at the keystroke.
24
+ Airpocket was choosing that route from the ids on record without asking
25
+ whether Superset was still there. The list now says why in a few words, and
26
+ says the thing that helps rather than suggesting `airpocket run`, which
27
+ wouldn't.
28
+
29
+ ### Added
30
+
31
+ - **`airpocket doctor` says when sessions in Superset terminals are
32
+ read-only**, counting only the ones that would really lose their route: an
33
+ agent started with `airpocket run` inside a Superset terminal is reached
34
+ through its tmux pane and is left alone.
35
+
7
36
  ## 0.1.0 - 2026-09-17
8
37
 
9
38
  The first version. Airpocket runs on each Mac where you use Claude Code or
package/README.md CHANGED
@@ -30,8 +30,9 @@ and on the phone.
30
30
 
31
31
  <p align="center"><img src="docs/images/phones.png" width="100%" alt="Three phones running Airpocket. First, the session list: three sessions need you (one asking permission to use Bash, one asking which Stripe API version to target, one finished) above two open ones. Second, a session asking &quot;Do you want to proceed?&quot; with three numbered answers and a reply box. Third, a TypeScript file the agent wrote, open with line numbers."></p>
32
32
 
33
- > **Status: private, work in progress.** Not on npm yet: to try it, install
34
- > from a clone (below). See [docs/PLAN.md](docs/PLAN.md), and
33
+ > **Status: 0.1.1, young.** It does what this page says and it is tested, but
34
+ > it has run on few Macs other than its author's. See
35
+ > [docs/PLAN.md](docs/PLAN.md) for how it was built, and
35
36
  > [CONTRIBUTING.md](CONTRIBUTING.md) to send a change.
36
37
 
37
38
  ## What it will do
@@ -119,20 +120,30 @@ it serves stays inside your tailnet.
119
120
  Sharing a tailnet doesn't share sessions: every Mac still asks the phone for a
120
121
  pairing code of its own, and hands out its own token.
121
122
 
122
- ### Try it from a clone
123
+ ### Install it
123
124
 
124
- Airpocket isn't on npm yet. Until it is, install it from a clone:
125
+ ```sh
126
+ npm install -g airpocket
127
+ airpocket setup --dry-run # every change it would make; changes nothing
128
+ airpocket setup
129
+ ```
130
+
131
+ No dependencies: the tarball is Airpocket and the fonts the phone app uses,
132
+ and nothing else.
133
+
134
+ ### Or from a clone
135
+
136
+ To read the code as you run it, or to send a change:
125
137
 
126
138
  ```sh
127
139
  git clone https://github.com/airpocket-sh/airpocket.git
128
140
  cd airpocket
129
- npm install -g . # puts the airpocket command on your PATH; no dependencies
130
- airpocket setup --dry-run # every change it would make; changes nothing
141
+ npm install -g . # the airpocket command, from your clone
131
142
  airpocket setup
132
143
  ```
133
144
 
134
- `npm install -g .` links the command to your clone. Setup copies Airpocket
135
- into its data folder and runs that copy (see
145
+ Either way, setup copies Airpocket into its data folder and runs that copy, so
146
+ the service keeps working if you move or delete the clone (see
136
147
  [Keeping it up to date](#keeping-it-up-to-date)).
137
148
 
138
149
  Setup checks first and stops with a plain explanation if something is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airpocket",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Your Claude Code and Codex sessions, from every Mac, on your phone.",
5
5
  "keywords": [
6
6
  "claude-code",
package/src/bridges.js CHANGED
@@ -44,7 +44,9 @@ const { execFile } = require("child_process");
44
44
  const { processInfo, pidOf } = require("./proc");
45
45
 
46
46
  const ESC = String.fromCharCode(27);
47
- const SUPERSET = path.join(os.homedir(), ".superset", "bin", "superset");
47
+ /** Superset's own command, under a home folder: the only way into a Superset terminal. */
48
+ const supersetBin = (home = os.homedir()) => path.join(home, ".superset", "bin", "superset");
49
+ const SUPERSET = supersetBin();
48
50
  // Homebrew on Apple silicon, Homebrew on Intel, MacPorts.
49
51
  const TMUX_BINS = Object.freeze(["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/opt/local/bin/tmux"]);
50
52
  // tmux takes one command of 16 KB at most (MAX_IMSGSIZE), so text goes in pieces well under that.
@@ -60,6 +62,12 @@ const NOT_IN_PANE = Object.freeze({ error: "not_in_pane",
60
62
  message: "Its agent isn't running in that tmux pane (it was only started from there), so Airpocket won't type into the pane." });
61
63
  const NOT_IN_TERMINAL = Object.freeze({ error: "not_in_terminal",
62
64
  message: "Its agent isn't running in a terminal (it was only started from one), so Airpocket won't type into it." });
65
+ const NO_SUPERSET = Object.freeze({ error: "no_superset",
66
+ message: "That terminal belongs to Superset, which isn't on this Mac, so the session is read-only here.",
67
+ why: "Superset isn't on this Mac, so its terminal can't be reached." });
68
+ const NO_ROUTE = Object.freeze({ error: "no_route",
69
+ message: "This session isn't in a terminal Airpocket can type into, so it's read-only here. Start it with airpocket run and the phone can answer.",
70
+ why: "Start it with airpocket run and the phone can answer." });
63
71
  const NOT_FOREGROUND = Object.freeze({ error: "not_foreground",
64
72
  message: "Something else holds its terminal right now (the agent is stopped, in the background, or behind another program), so Airpocket won't type into it." });
65
73
 
@@ -93,7 +101,7 @@ function runFile(file, args, timeout = 15000, env) {
93
101
  function runSuperset(args, o = {}) { return runFile(SUPERSET, args, o.timeout || 15000); }
94
102
 
95
103
  const executable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
96
- const supersetInstalled = () => executable(SUPERSET);
104
+ const supersetInstalled = (home) => executable(home ? supersetBin(home) : SUPERSET);
97
105
 
98
106
  // The tmux program: the first of the two usual places that holds one. Looked
99
107
  // for again at most once a minute, so installing tmux needs no restart.
@@ -147,6 +155,9 @@ function createBridges(o = {}) {
147
155
  const superset = o.superset || runSuperset;
148
156
  const tmux = o.tmux || runTmux;
149
157
  const hasTmux = o.hasTmux || (() => !!findTmux());
158
+ // Superset's CLI is what reaches a Superset terminal: without it a recorded
159
+ // workspace and terminal id name a terminal nothing here can type into.
160
+ const hasSuperset = o.hasSuperset || (o.superset ? () => true : supersetInstalled);
150
161
  const uid = o.uid ?? process.getuid();
151
162
  const inspect = o.inspect || processInfo;
152
163
  // A burst of text and an Enter right behind it reads as a paste with a line
@@ -207,6 +218,11 @@ function createBridges(o = {}) {
207
218
  /** Refused unless the agent's process group holds its terminal; else `then`. */
208
219
  const foreground = (agent, then) => (agent.pgid > 0 && agent.pgid === agent.tpgid ? then : NOT_FOREGROUND);
209
220
 
221
+ /** A record that names a Superset terminal, whether or not Superset is still here. */
222
+ const namesSuperset = (e) => !!e && TERMINAL_ID.test(String(e.workspace || "")) && TERMINAL_ID.test(String(e.terminal || ""));
223
+ /** Why there is no bridge: Superset gone, or never started where we could type. */
224
+ const noRoute = (e) => (namesSuperset(e) ? NO_SUPERSET : NO_ROUTE);
225
+
210
226
  /** The bridge to a session's terminal, from its record; null when it has none (read-only). */
211
227
  function of(e) {
212
228
  if (!e) return null;
@@ -214,7 +230,7 @@ function createBridges(o = {}) {
214
230
  const sock = tmuxSocket(e.tmux, uid);
215
231
  if (sock) return tmuxBridge(sock, e.pane, e.tmux_pid);
216
232
  }
217
- if (TERMINAL_ID.test(String(e.workspace || "")) && TERMINAL_ID.test(String(e.terminal || ""))) return supersetBridge(e.workspace, e.terminal);
233
+ if (namesSuperset(e) && hasSuperset()) return supersetBridge(e.workspace, e.terminal);
218
234
  return null;
219
235
  }
220
236
 
@@ -227,7 +243,7 @@ function createBridges(o = {}) {
227
243
  const b = of(e);
228
244
  const known = !!(e && e.pid && e.started);
229
245
  const can = !!b && known && !!live;
230
- const why = !b ? "Start it with airpocket run and the phone can answer." : !known ? "Airpocket doesn't know which process runs it."
246
+ const why = !b ? noRoute(e).why : !known ? "Airpocket doesn't know which process runs it."
231
247
  : !live ? "Its agent has exited." : "";
232
248
  return { bridge: b ? b.name : null, read: !!b, send: can, keys: can, controls: can, why };
233
249
  }
@@ -239,7 +255,7 @@ function createBridges(o = {}) {
239
255
  */
240
256
  async function writable(e) {
241
257
  const b = of(e);
242
- if (!b) return { error: "no_route", message: "This session isn't in a terminal Airpocket can type into, so it's read-only here. Start it with airpocket run and the phone can answer." };
258
+ if (!b) { const r = noRoute(e); return { error: r.error, message: r.message }; }
243
259
  const started = e && Number.isSafeInteger(e.started) && e.started > 0 ? e.started : 0;
244
260
  const agent = pidOf(e.pid) && started ? inspect(e.pid) : null;
245
261
  if (!agent || agent.started !== started) return { error: "ended", message: "Its agent has exited, so Airpocket won't type into that terminal." };
@@ -251,4 +267,4 @@ function createBridges(o = {}) {
251
267
  return { of, caps, writable };
252
268
  }
253
269
 
254
- module.exports = { createBridges, KEYS, tmuxSocket, tmuxLiteral, tmuxPieces, runFile, runSuperset, runTmux, findTmux, supersetInstalled, TMUX_BINS };
270
+ module.exports = { createBridges, KEYS, supersetBin, tmuxSocket, tmuxLiteral, tmuxPieces, runFile, runSuperset, runTmux, findTmux, supersetInstalled, TMUX_BINS };
package/src/doctor.js CHANGED
@@ -9,7 +9,9 @@ const ts = require("./tailscale");
9
9
  const li = require("./loginitem");
10
10
  const sa = require("./shellalias");
11
11
  const own = require("./ownterm");
12
- const { survey, probe, majorOf, SOCKET_MAX, ownSessions } = require("./setup");
12
+ const { survey, probe, majorOf, SOCKET_MAX, ownSessions, sessionRecords } = require("./setup");
13
+ const { supersetInstalled, createBridges } = require("./bridges");
14
+ const { stillRunning } = require("./proc");
13
15
 
14
16
  async function checks(ctx) {
15
17
  const f = await survey(ctx);
@@ -129,6 +131,23 @@ async function checks(ctx) {
129
131
  const running = ownSessions(ctx).length;
130
132
  add("PASS", "tmux", `${f.tmuxVersion ? f.tmuxVersion + " " : ""}(${f.tmux})${running ? `, ${running} agent${running === 1 ? "" : "s"} started with airpocket run` : ""}`);
131
133
  }
134
+ // Superset: its CLI is the only way to reach a Superset terminal. Without it,
135
+ // sessions recorded in one are read-only on the phone, which is worth saying
136
+ // before someone finds out by typing.
137
+ // An agent started with airpocket run inside a Superset terminal has both
138
+ // addresses on record, and its tmux pane is the one the phone types into. So
139
+ // ask the same chooser the service asks, with Superset taken away: what it
140
+ // still routes needs no Superset, and only the rest would go read-only.
141
+ const withoutSuperset = createBridges({ hasSuperset: () => false, hasTmux: () => !!f.tmux, uid: ctx.uid });
142
+ const live = sessionRecords(ctx).filter((r) => r && r.workspace && r.terminal && stillRunning(r.pid, r.started));
143
+ const stranded = live.filter((r) => !withoutSuperset.of(r)).length;
144
+ const sup = supersetInstalled(ctx.home);
145
+ const agents = (n) => `${n} session${n === 1 ? "" : "s"}`;
146
+ if (sup) add("PASS", "Superset", live.length ? `installed, ${agents(live.length)} in a Superset terminal` : "installed");
147
+ else if (stranded) add("WARN", "Superset", `not installed, and ${agents(stranded)} run in a Superset terminal: read-only on the phone`,
148
+ "Those terminals can only be typed into through Superset's own command. Start the agent with airpocket run instead, or install Superset.");
149
+ else add("INFO", "Superset", "not installed: new sessions can't be started from the phone, and nothing here needs it");
150
+
132
151
  if (aliases && aliased.length) add("PASS", "Shell aliases", `${t(aliases)}: ${aliased.join(", ")} start with airpocket run`);
133
152
  else if (aliases) add("WARN", "Shell aliases", `setup added them to ${t(aliases)}, and they're gone`, "Add them again with: airpocket setup --alias");
134
153
  else add("INFO", "Shell aliases", "not added: sessions started the usual way are read-only on the phone outside Superset and tmux", "Add them with: airpocket setup --alias");
package/src/server.js CHANGED
@@ -916,6 +916,7 @@ function createServer(opts = {}) {
916
916
  superset: deps.superset,
917
917
  tmux: opts.tmux || runTmux,
918
918
  hasTmux: opts.hasTmux || (opts.tmux ? () => true : () => !!findTmux()),
919
+ hasSuperset: deps.hasSuperset,
919
920
  uid: opts.uid,
920
921
  inspect: opts.inspectAgent,
921
922
  gap: opts.typeGap,
@@ -1184,6 +1185,24 @@ function createServer(opts = {}) {
1184
1185
  return null;
1185
1186
  }
1186
1187
 
1188
+ /**
1189
+ * A write to a terminal that passed writable() a moment ago. What the terminal's
1190
+ * own program reports becomes an answer that names it, instead of an unhandled
1191
+ * failure the phone can only call "something went wrong on the Mac". The typed
1192
+ * text never reaches the message: runFile keeps it out (see bridges.js).
1193
+ * True when the keys landed.
1194
+ */
1195
+ async function typedOr502(bridge, res, cors, write) {
1196
+ try { await write(); return true; } catch (err) {
1197
+ const why = String((err && err.message) || "").trim().slice(0, 200);
1198
+ const where = bridge.name === "tmux" ? "tmux pane" : "Superset terminal";
1199
+ deps.log(`write failed (${bridge.name})${why ? ": " + why : ""}`);
1200
+ json(res, 502, { error: "write_failed",
1201
+ message: `Couldn't type into its ${where}.${why ? " " + why + "." : ""} It may have closed; airpocket doctor on the Mac says more.` }, cors);
1202
+ return false;
1203
+ }
1204
+ }
1205
+
1187
1206
  async function pair(res, body, cors) {
1188
1207
  const r = deps.store.useCode(body.code);
1189
1208
  if (r === "ok") {
@@ -1279,7 +1298,7 @@ function createServer(opts = {}) {
1279
1298
  const submit = body.submit !== false;
1280
1299
  const bridge = await writableOr409(e, res, cors);
1281
1300
  if (!bridge) return res.statusCode;
1282
- await bridge.type(full, submit);
1301
+ if (!(await typedOr502(bridge, res, cors, () => bridge.type(full, submit)))) return res.statusCode;
1283
1302
  return json(res, 200, { ok: true, via: "terminal", submitted: submit, photos: shots.length }, cors);
1284
1303
  }
1285
1304
 
@@ -1314,7 +1333,7 @@ function createServer(opts = {}) {
1314
1333
  if (!key) return json(res, 400, { error: "bad_key" }, cors);
1315
1334
  const bridge = await writableOr409(e, res, cors);
1316
1335
  if (!bridge) return res.statusCode;
1317
- await bridge.press(key);
1336
+ if (!(await typedOr502(bridge, res, cors, () => bridge.press(key)))) return res.statusCode;
1318
1337
  return json(res, 200, { ok: true }, cors);
1319
1338
  }
1320
1339
 
@@ -1495,9 +1514,12 @@ function createServer(opts = {}) {
1495
1514
  if (live.menu) return json(res, 409, { error: "menu_open", message: "Answer the question on screen first." }, cors);
1496
1515
  if (live.staged) return json(res, 409, { error: "typed_text", message: "There's unsent text in its box. Send or clear it first." }, cors);
1497
1516
  if (live.state === "working") return json(res, 409, { error: "busy", message: "It's working. Wait for it to finish, or press Esc first." }, cors);
1498
- await bridge.type(text, false);
1499
- await wait(300); // let the slash-command list catch up before Enter
1500
- await bridge.type("", true);
1517
+ const typed = await typedOr502(bridge, res, cors, async () => {
1518
+ await bridge.type(text, false);
1519
+ await wait(300); // let the slash-command list catch up before Enter
1520
+ await bridge.type("", true);
1521
+ });
1522
+ if (!typed) return res.statusCode;
1501
1523
  return json(res, 200, { ok: true, sent: text }, cors);
1502
1524
  }
1503
1525
 
package/src/setup.js CHANGED
@@ -633,16 +633,22 @@ const UNSEEN = {
633
633
  };
634
634
  const unseenLine = (kind, n) => (n === 1 ? UNSEEN[kind][0] : `${n} ${UNSEEN[kind][1]}`);
635
635
 
636
- /**
637
- * Why the phone may not show every agent yet, right after setup: agents running
638
- * in a terminal that no session record names, and Codex hooks not trusted yet.
639
- */
640
- function unseenLines(ctx) {
636
+ /** The service's session records, as they are on disk. Reads nothing else. */
637
+ function sessionRecords(ctx) {
641
638
  const records = [];
642
639
  const dir = path.join(ctx.paths.dataDir, "sessions");
643
640
  let names = [];
644
641
  try { names = fs.readdirSync(dir).filter((n) => n.endsWith(".json")); } catch {}
645
642
  for (const name of names.slice(0, 5000)) { const r = readJson(path.join(dir, name)); if (r && typeof r === "object") records.push(r); }
643
+ return records;
644
+ }
645
+
646
+ /**
647
+ * Why the phone may not show every agent yet, right after setup: agents running
648
+ * in a terminal that no session record names, and Codex hooks not trusted yet.
649
+ */
650
+ function unseenLines(ctx) {
651
+ const records = sessionRecords(ctx);
646
652
  const scan = ctx.processes() || {};
647
653
  const n = byAgent(unrecordedAgents(Array.isArray(scan.agents) ? scan.agents : [], records));
648
654
  const lines = ["claude", "codex"].filter((kind) => n[kind]).map((kind) => unseenLine(kind, n[kind]));
@@ -828,4 +834,4 @@ function listBackups(ctx) {
828
834
  }
829
835
 
830
836
  module.exports = { context, stableNode, survey, planSetup, runSetup, unseenLines, unseenLine, UNSEEN, planUninstall, runUninstall, pairingLines, readLock, readInstall,
831
- probe, listening, versionOf, majorOf, SOCKET_MAX, copyApp, tail, unsafeDataDir, ownSessions };
837
+ probe, listening, versionOf, majorOf, SOCKET_MAX, copyApp, tail, unsafeDataDir, ownSessions, sessionRecords};