carmoji 0.3.2 → 0.3.3

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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/carmoji.js +144 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -38,6 +38,12 @@ approval.
38
38
  through the system's `dns-sd`, which handles Macs full of VPN tunnels
39
39
  and virtual interfaces; other platforms use a JS mDNS fallback.
40
40
 
41
+ Pairing is additive: pair again with a second phone and every event
42
+ fans out to all paired devices (approval requests go to all of them
43
+ too — the first tap wins). When devices are already paired, `pair`
44
+ asks whether to keep them. `npx carmoji unpair` forgets a device —
45
+ by name or address, or `unpair all` for a clean slate.
46
+
41
47
  ## Claude Code
42
48
 
43
49
  Coding in *this* repo works out of the box — the hooks ship in
package/carmoji.js CHANGED
@@ -6,6 +6,8 @@
6
6
  //
7
7
  // Commands:
8
8
  // pair <code> discover the phone on this Wi-Fi and pair with it
9
+ // (pairing is additive — events fan out to every device)
10
+ // unpair [device] forget a paired device by name or ip[:port], or `all`
9
11
  // install <tool> install hooks for a tool (or `all`); see `tools`
10
12
  // uninstall <tool> remove CarMoji hooks from a tool's config
11
13
  // tools list supported tools and where their hooks live
@@ -52,6 +54,30 @@ function saveConfig(config) {
52
54
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
53
55
  }
54
56
 
57
+ /**
58
+ * Paired devices from a config of either vintage: the original single
59
+ * top-level {host, port, code}, or the current {devices: [{name, host,
60
+ * port, code}, …]}. Saving always writes the multi-device shape.
61
+ */
62
+ function normalizeDevices(config) {
63
+ if (!config) return [];
64
+ const list = Array.isArray(config.devices) ? config.devices
65
+ : config.host ? [config] : [];
66
+ return list.filter((d) => d && d.host && d.port && d.code);
67
+ }
68
+
69
+ function loadDevices() {
70
+ return normalizeDevices(loadConfig());
71
+ }
72
+
73
+ function saveDevices(devices) {
74
+ saveConfig({ devices });
75
+ }
76
+
77
+ function deviceLabel(device) {
78
+ return `${device.name ?? device.host} — ${device.host}:${device.port}`;
79
+ }
80
+
55
81
  function readStdin() {
56
82
  try {
57
83
  return readFileSync(0, 'utf8');
@@ -135,6 +161,14 @@ async function discoverViaMulticastJS() {
135
161
  });
136
162
  }
137
163
 
164
+ async function askTTY(question) {
165
+ const { createInterface } = await import('node:readline/promises');
166
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
167
+ const answer = await rl.question(question);
168
+ rl.close();
169
+ return answer.trim();
170
+ }
171
+
138
172
  /** Let the user pick between several discovered phones. */
139
173
  async function chooseDevice(devices) {
140
174
  console.log('Multiple CarMoji devices found:');
@@ -143,10 +177,7 @@ async function chooseDevice(devices) {
143
177
  console.error('\nRe-run with the address of the one you want: carmoji pair <code> <ip[:port]>');
144
178
  process.exit(1);
145
179
  }
146
- const { createInterface } = await import('node:readline/promises');
147
- const rl = createInterface({ input: process.stdin, output: process.stdout });
148
- const answer = await rl.question(`Pair with which one? [1-${devices.length}] `);
149
- rl.close();
180
+ const answer = await askTTY(`Pair with which one? [1-${devices.length}] `);
150
181
  const device = devices[Number(answer) - 1];
151
182
  if (!device) {
152
183
  console.error('Invalid choice.');
@@ -220,6 +251,30 @@ async function requestApproval(config, message, timeoutMs = 55_000) {
220
251
  });
221
252
  }
222
253
 
254
+ /** Fan one event out to every paired phone in parallel; one slow or
255
+ * unreachable device can't delay the others past SEND_TIMEOUT_MS. */
256
+ function sendAll(devices, message) {
257
+ return Promise.all(devices.map((device) => send(device, message)));
258
+ }
259
+
260
+ /**
261
+ * Show the approval card on every paired phone; the first tap anywhere
262
+ * wins. The other phones' cards drop when this process exits and their
263
+ * sockets close. Resolves null when nobody answers in time.
264
+ */
265
+ function requestApprovalAll(devices, message, timeoutMs) {
266
+ if (devices.length === 0) return Promise.resolve(null);
267
+ return new Promise((resolve) => {
268
+ let pending = devices.length;
269
+ for (const device of devices) {
270
+ requestApproval(device, message, timeoutMs).then((decision) => {
271
+ pending -= 1;
272
+ if (decision || pending === 0) resolve(decision);
273
+ });
274
+ }
275
+ });
276
+ }
277
+
223
278
  function summarizeToolInput(payload) {
224
279
  const input = payload.tool_input || {};
225
280
  const text = input.command || input.file_path || input.description
@@ -946,27 +1001,92 @@ async function main() {
946
1001
  }
947
1002
  target = devices.length === 1 ? devices[0] : await chooseDevice(devices);
948
1003
  }
949
- const config = { host: target.host, port: target.port, code: arg };
950
- const result = await send(config, { source: 'bridge', event: 'turn_done' });
1004
+ const device = { name: target.name, host: target.host,
1005
+ port: target.port, code: arg };
1006
+ const result = await send(device, { source: 'bridge', event: 'turn_done' });
951
1007
  if (result !== 'ok') {
952
1008
  console.error(result === 'bad-code'
953
1009
  ? `"${target.name}" (${target.host}:${target.port}) rejected the pairing code — check that device’s Code Buddy sheet. (Wrong phone? Pass its address: carmoji pair <code> <ip[:port]>)`
954
1010
  : `Found "${target.name}" at ${target.host}:${target.port} but couldn’t talk to it (${result}).`);
955
1011
  process.exit(1);
956
1012
  }
957
- saveConfig(config);
1013
+ // Pairing the same device again just refreshes its code; other
1014
+ // devices stay paired unless the user says otherwise here.
1015
+ const others = loadDevices().filter(
1016
+ (d) => !(d.host === device.host && Number(d.port) === Number(device.port)));
1017
+ let kept = others;
1018
+ if (others.length > 0) {
1019
+ console.log('Already paired with:');
1020
+ others.forEach((d) => console.log(` ${deviceLabel(d)}`));
1021
+ if (process.stdin.isTTY) {
1022
+ const answer = await askTTY(
1023
+ 'Keep them too? Events go to every paired device. [Y/n] ');
1024
+ if (/^n/i.test(answer)) {
1025
+ kept = [];
1026
+ console.log('Unpaired them — events go only to the new device.');
1027
+ }
1028
+ } else {
1029
+ console.log('Keeping them — events fan out to every paired device.'
1030
+ + ' (`carmoji unpair` removes one.)');
1031
+ }
1032
+ }
1033
+ saveDevices([...kept, device]);
958
1034
  console.log(`Paired with "${target.name}" at ${target.host}:${target.port} — the pet should be celebrating!`);
959
1035
  break;
960
1036
  }
961
1037
 
1038
+ case 'unpair': {
1039
+ const devices = loadDevices();
1040
+ if (devices.length === 0) {
1041
+ console.log('Not paired with any device.');
1042
+ break;
1043
+ }
1044
+ let goners;
1045
+ if (arg === 'all') {
1046
+ goners = devices;
1047
+ } else if (arg) {
1048
+ goners = devices.filter((d) => d.name === arg || d.host === arg
1049
+ || `${d.host}:${d.port}` === arg);
1050
+ if (goners.length === 0) {
1051
+ console.error(`No paired device matches "${arg}". Paired:`);
1052
+ devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
1053
+ process.exit(1);
1054
+ }
1055
+ } else if (devices.length === 1) {
1056
+ goners = devices;
1057
+ } else if (process.stdin.isTTY) {
1058
+ console.log('Paired devices:');
1059
+ devices.forEach((d, i) => console.log(` ${i + 1}. ${deviceLabel(d)}`));
1060
+ const answer = await askTTY(
1061
+ `Unpair which one? [1-${devices.length}, or "all"] `);
1062
+ if (answer.toLowerCase() === 'all') {
1063
+ goners = devices;
1064
+ } else {
1065
+ const device = devices[Number(answer) - 1];
1066
+ if (!device) {
1067
+ console.error('Invalid choice.');
1068
+ process.exit(1);
1069
+ }
1070
+ goners = [device];
1071
+ }
1072
+ } else {
1073
+ console.error('Several devices are paired — name one: carmoji unpair <name|ip[:port]|all>');
1074
+ devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
1075
+ process.exit(1);
1076
+ }
1077
+ saveDevices(devices.filter((d) => !goners.includes(d)));
1078
+ goners.forEach((d) => console.log(`Unpaired ${deviceLabel(d)}.`));
1079
+ break;
1080
+ }
1081
+
962
1082
  case 'hook': {
963
1083
  // Runs inside the coding agent on every hook — must be silent, quick,
964
1084
  // and always exit 0 so it can never disturb the session.
965
1085
  // Flags: --source <tool> (default claude), --event <name> for tools
966
1086
  // whose stdin lacks the event name, --gate for phone approvals.
967
1087
  try {
968
- const config = loadConfig();
969
- if (!config) break;
1088
+ const devices = loadDevices();
1089
+ if (devices.length === 0) break;
970
1090
  const flags = [arg, ...rest].filter(Boolean);
971
1091
  const flagValue = (name) => {
972
1092
  const index = flags.indexOf(name);
@@ -980,7 +1100,7 @@ async function main() {
980
1100
  // the phone shows Allow/Deny; the printed JSON settles the
981
1101
  // permission. Timeout falls through to the normal terminal prompt.
982
1102
  if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
983
- const decision = await requestApproval(config, {
1103
+ const decision = await requestApprovalAll(devices, {
984
1104
  source,
985
1105
  session: payload.session_id,
986
1106
  event: 'approval_request',
@@ -1032,7 +1152,7 @@ async function main() {
1032
1152
  const usage = claudeUsage(recompute);
1033
1153
  if (usage) message.usage = usage;
1034
1154
  }
1035
- await send(config, message);
1155
+ await sendAll(devices, message);
1036
1156
  } catch {
1037
1157
  // Never surface errors into the coding session.
1038
1158
  }
@@ -1041,14 +1161,14 @@ async function main() {
1041
1161
 
1042
1162
  case 'codex-notify': {
1043
1163
  try {
1044
- const config = loadConfig();
1045
- if (!config) break;
1164
+ const devices = loadDevices();
1165
+ if (devices.length === 0) break;
1046
1166
  const payload = JSON.parse(process.argv[process.argv.length - 1] || '{}');
1047
1167
  if (payload.type === 'agent-turn-complete') {
1048
1168
  const session = payload['conversation-id'] || payload.conversation_id;
1049
1169
  const message = { source: 'codex', session, event: 'turn_done' };
1050
1170
  if (payload.cwd) message.project = basename(payload.cwd);
1051
- await send(config, message);
1171
+ await sendAll(devices, message);
1052
1172
  }
1053
1173
  } catch {
1054
1174
  // Same rule as `hook`: stay silent.
@@ -1062,8 +1182,8 @@ async function main() {
1062
1182
  console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}> [session] [tokens]`);
1063
1183
  process.exit(1);
1064
1184
  }
1065
- const config = loadConfig();
1066
- if (!config) {
1185
+ const devices = loadDevices();
1186
+ if (devices.length === 0) {
1067
1187
  console.error('Not paired yet — run: carmoji pair <code>');
1068
1188
  process.exit(1);
1069
1189
  }
@@ -1073,9 +1193,12 @@ async function main() {
1073
1193
  if (project) message.project = project;
1074
1194
  const usage = claudeUsage(true);
1075
1195
  if (usage) message.usage = usage;
1076
- const result = await send(config, message);
1077
- console.log(result === 'ok' ? 'Sent!' : `Failed: ${result}`);
1078
- process.exit(result === 'ok' ? 0 : 1);
1196
+ const results = await sendAll(devices, message);
1197
+ results.forEach((result, index) => {
1198
+ console.log(` ${deviceLabel(devices[index])}: ${
1199
+ result === 'ok' ? 'sent!' : `failed (${result})`}`);
1200
+ });
1201
+ process.exit(results.every((result) => result === 'ok') ? 0 : 1);
1079
1202
  break;
1080
1203
  }
1081
1204
 
@@ -1167,13 +1290,14 @@ async function main() {
1167
1290
  break;
1168
1291
 
1169
1292
  default:
1170
- console.log('carmoji bridge — usage: pair <code> [ip[:port]] | discover | install <tool>|all | uninstall <tool> | tools | test <event> | hook [--source <tool>] [--event <name>] [--gate] | codex-notify | claude-config | codex-config | gate-config');
1293
+ console.log('carmoji bridge — usage: pair <code> [ip[:port]] | unpair [<name|ip[:port]>|all] | discover | install <tool>|all | uninstall <tool> | tools | test <event> | hook [--source <tool>] [--event <name>] [--gate] | codex-notify | claude-config | codex-config | gate-config');
1171
1294
  }
1172
1295
  }
1173
1296
 
1174
1297
  export {
1175
1298
  applyTranscriptTokenRecord,
1176
1299
  eventFromHook,
1300
+ normalizeDevices,
1177
1301
  stripLegacyCarMojiNotifyLine,
1178
1302
  };
1179
1303
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Bring your CarMoji pet to life from Claude Code / Codex — a LAN bridge that streams coding events to the CarMoji iOS app",
5
5
  "type": "module",
6
6
  "bin": {