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 +29 -0
- package/README.md +19 -8
- package/package.json +1 -1
- package/src/bridges.js +22 -6
- package/src/doctor.js +20 -1
- package/src/server.js +27 -5
- package/src/setup.js +12 -6
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 "Do you want to proceed?" with three numbered answers and a reply box. Third, a TypeScript file the agent wrote, open with line numbers."></p>
|
|
32
32
|
|
|
33
|
-
> **Status:
|
|
34
|
-
>
|
|
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
|
-
###
|
|
123
|
+
### Install it
|
|
123
124
|
|
|
124
|
-
|
|
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 . #
|
|
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
|
-
|
|
135
|
-
|
|
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
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
|
-
|
|
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 (
|
|
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 ?
|
|
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:
|
|
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
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
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};
|