carmoji 0.3.2 → 0.3.4

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 +174 -31
  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
@@ -5,7 +5,10 @@
5
5
  // address + pairing code in ~/.config/carmoji/config.json.
6
6
  //
7
7
  // Commands:
8
- // pair <code> discover the phone on this Wi-Fi and pair with it
8
+ // pair <code> discover phones on this Wi-Fi and pair with the one
9
+ // whose Code Buddy sheet shows <code>
10
+ // (pairing is additive — events fan out to every device)
11
+ // unpair [device] forget a paired device by name or ip[:port], or `all`
9
12
  // install <tool> install hooks for a tool (or `all`); see `tools`
10
13
  // uninstall <tool> remove CarMoji hooks from a tool's config
11
14
  // tools list supported tools and where their hooks live
@@ -52,6 +55,30 @@ function saveConfig(config) {
52
55
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
53
56
  }
54
57
 
58
+ /**
59
+ * Paired devices from a config of either vintage: the original single
60
+ * top-level {host, port, code}, or the current {devices: [{name, host,
61
+ * port, code}, …]}. Saving always writes the multi-device shape.
62
+ */
63
+ function normalizeDevices(config) {
64
+ if (!config) return [];
65
+ const list = Array.isArray(config.devices) ? config.devices
66
+ : config.host ? [config] : [];
67
+ return list.filter((d) => d && d.host && d.port && d.code);
68
+ }
69
+
70
+ function loadDevices() {
71
+ return normalizeDevices(loadConfig());
72
+ }
73
+
74
+ function saveDevices(devices) {
75
+ saveConfig({ devices });
76
+ }
77
+
78
+ function deviceLabel(device) {
79
+ return `${device.name ?? device.host} — ${device.host}:${device.port}`;
80
+ }
81
+
55
82
  function readStdin() {
56
83
  try {
57
84
  return readFileSync(0, 'utf8');
@@ -135,18 +162,23 @@ async function discoverViaMulticastJS() {
135
162
  });
136
163
  }
137
164
 
165
+ async function askTTY(question) {
166
+ const { createInterface } = await import('node:readline/promises');
167
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
168
+ const answer = await rl.question(question);
169
+ rl.close();
170
+ return answer.trim();
171
+ }
172
+
138
173
  /** Let the user pick between several discovered phones. */
139
- async function chooseDevice(devices) {
140
- console.log('Multiple CarMoji devices found:');
174
+ async function chooseDevice(devices, heading = 'Multiple CarMoji devices found:') {
175
+ console.log(heading);
141
176
  devices.forEach((d, i) => console.log(` ${i + 1}. ${d.name} — ${d.host}:${d.port}`));
142
177
  if (!process.stdin.isTTY) {
143
178
  console.error('\nRe-run with the address of the one you want: carmoji pair <code> <ip[:port]>');
144
179
  process.exit(1);
145
180
  }
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();
181
+ const answer = await askTTY(`Pair with which one? [1-${devices.length}] `);
150
182
  const device = devices[Number(answer) - 1];
151
183
  if (!device) {
152
184
  console.error('Invalid choice.');
@@ -220,6 +252,30 @@ async function requestApproval(config, message, timeoutMs = 55_000) {
220
252
  });
221
253
  }
222
254
 
255
+ /** Fan one event out to every paired phone in parallel; one slow or
256
+ * unreachable device can't delay the others past SEND_TIMEOUT_MS. */
257
+ function sendAll(devices, message) {
258
+ return Promise.all(devices.map((device) => send(device, message)));
259
+ }
260
+
261
+ /**
262
+ * Show the approval card on every paired phone; the first tap anywhere
263
+ * wins. The other phones' cards drop when this process exits and their
264
+ * sockets close. Resolves null when nobody answers in time.
265
+ */
266
+ function requestApprovalAll(devices, message, timeoutMs) {
267
+ if (devices.length === 0) return Promise.resolve(null);
268
+ return new Promise((resolve) => {
269
+ let pending = devices.length;
270
+ for (const device of devices) {
271
+ requestApproval(device, message, timeoutMs).then((decision) => {
272
+ pending -= 1;
273
+ if (decision || pending === 0) resolve(decision);
274
+ });
275
+ }
276
+ });
277
+ }
278
+
223
279
  function summarizeToolInput(payload) {
224
280
  const input = payload.tool_input || {};
225
281
  const text = input.command || input.file_path || input.description
@@ -932,10 +988,18 @@ async function main() {
932
988
  let target;
933
989
  const manual = rest[0];
934
990
  if (manual) {
935
- // Manual addressing for networks with several phones (the app's
936
- // Code Buddy sheet shows the address) or when mDNS is blocked.
991
+ // Manual addressing (the app's Code Buddy sheet shows the address)
992
+ // for networks where mDNS is blocked.
937
993
  const [host, port] = manual.split(':');
938
994
  target = { host, port: Number(port) || 8737, name: host };
995
+ const result = await send({ ...target, code: arg },
996
+ { source: 'bridge', event: 'turn_done' });
997
+ if (result !== 'ok') {
998
+ console.error(result === 'bad-code'
999
+ ? `"${target.name}" (${target.host}:${target.port}) rejected the pairing code — check that device’s Code Buddy sheet.`
1000
+ : `Found "${target.name}" at ${target.host}:${target.port} but couldn’t talk to it (${result}).`);
1001
+ process.exit(1);
1002
+ }
939
1003
  } else {
940
1004
  console.log('Looking for CarMoji on this network…');
941
1005
  const devices = await discoverAll();
@@ -944,29 +1008,104 @@ async function main() {
944
1008
  console.error('You can also pair by address: carmoji pair <code> <ip[:port]>');
945
1009
  process.exit(1);
946
1010
  }
947
- target = devices.length === 1 ? devices[0] : await chooseDevice(devices);
1011
+ // The pairing code identifies the phone: probe every discovered
1012
+ // device with it and pair with whichever accepts — no picking from
1013
+ // a list when several phones share the network.
1014
+ const results = await Promise.all(devices.map((d) =>
1015
+ send({ ...d, code: arg }, { source: 'bridge', event: 'turn_done' })));
1016
+ const accepted = devices.filter((_, i) => results[i] === 'ok');
1017
+ if (accepted.length === 0) {
1018
+ console.error(results.includes('bad-code')
1019
+ ? `No device on this network accepted code ${arg} — check the Code Buddy sheet on the phone you want:`
1020
+ : 'Found CarMoji but couldn’t talk to it:');
1021
+ devices.forEach((d, i) =>
1022
+ console.error(` ${deviceLabel(d)}: ${results[i]}`));
1023
+ process.exit(1);
1024
+ }
1025
+ // Two phones drew the same 4-digit code — let the user break the
1026
+ // tie instead of guessing.
1027
+ target = accepted.length === 1 ? accepted[0]
1028
+ : await chooseDevice(accepted, 'Several devices accepted this pairing code:');
948
1029
  }
949
- const config = { host: target.host, port: target.port, code: arg };
950
- const result = await send(config, { source: 'bridge', event: 'turn_done' });
951
- if (result !== 'ok') {
952
- console.error(result === 'bad-code'
953
- ? `"${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
- : `Found "${target.name}" at ${target.host}:${target.port} but couldn’t talk to it (${result}).`);
955
- process.exit(1);
1030
+ const device = { name: target.name, host: target.host,
1031
+ port: target.port, code: arg };
1032
+ // Pairing the same device again just refreshes its code; other
1033
+ // devices stay paired unless the user says otherwise here.
1034
+ const others = loadDevices().filter(
1035
+ (d) => !(d.host === device.host && Number(d.port) === Number(device.port)));
1036
+ let kept = others;
1037
+ if (others.length > 0) {
1038
+ console.log('Already paired with:');
1039
+ others.forEach((d) => console.log(` ${deviceLabel(d)}`));
1040
+ if (process.stdin.isTTY) {
1041
+ const answer = await askTTY(
1042
+ 'Keep them too? Events go to every paired device. [Y/n] ');
1043
+ if (/^n/i.test(answer)) {
1044
+ kept = [];
1045
+ console.log('Unpaired them — events go only to the new device.');
1046
+ }
1047
+ } else {
1048
+ console.log('Keeping them — events fan out to every paired device.'
1049
+ + ' (`carmoji unpair` removes one.)');
1050
+ }
956
1051
  }
957
- saveConfig(config);
1052
+ saveDevices([...kept, device]);
958
1053
  console.log(`Paired with "${target.name}" at ${target.host}:${target.port} — the pet should be celebrating!`);
959
1054
  break;
960
1055
  }
961
1056
 
1057
+ case 'unpair': {
1058
+ const devices = loadDevices();
1059
+ if (devices.length === 0) {
1060
+ console.log('Not paired with any device.');
1061
+ break;
1062
+ }
1063
+ let goners;
1064
+ if (arg === 'all') {
1065
+ goners = devices;
1066
+ } else if (arg) {
1067
+ goners = devices.filter((d) => d.name === arg || d.host === arg
1068
+ || `${d.host}:${d.port}` === arg);
1069
+ if (goners.length === 0) {
1070
+ console.error(`No paired device matches "${arg}". Paired:`);
1071
+ devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
1072
+ process.exit(1);
1073
+ }
1074
+ } else if (devices.length === 1) {
1075
+ goners = devices;
1076
+ } else if (process.stdin.isTTY) {
1077
+ console.log('Paired devices:');
1078
+ devices.forEach((d, i) => console.log(` ${i + 1}. ${deviceLabel(d)}`));
1079
+ const answer = await askTTY(
1080
+ `Unpair which one? [1-${devices.length}, or "all"] `);
1081
+ if (answer.toLowerCase() === 'all') {
1082
+ goners = devices;
1083
+ } else {
1084
+ const device = devices[Number(answer) - 1];
1085
+ if (!device) {
1086
+ console.error('Invalid choice.');
1087
+ process.exit(1);
1088
+ }
1089
+ goners = [device];
1090
+ }
1091
+ } else {
1092
+ console.error('Several devices are paired — name one: carmoji unpair <name|ip[:port]|all>');
1093
+ devices.forEach((d) => console.error(` ${deviceLabel(d)}`));
1094
+ process.exit(1);
1095
+ }
1096
+ saveDevices(devices.filter((d) => !goners.includes(d)));
1097
+ goners.forEach((d) => console.log(`Unpaired ${deviceLabel(d)}.`));
1098
+ break;
1099
+ }
1100
+
962
1101
  case 'hook': {
963
1102
  // Runs inside the coding agent on every hook — must be silent, quick,
964
1103
  // and always exit 0 so it can never disturb the session.
965
1104
  // Flags: --source <tool> (default claude), --event <name> for tools
966
1105
  // whose stdin lacks the event name, --gate for phone approvals.
967
1106
  try {
968
- const config = loadConfig();
969
- if (!config) break;
1107
+ const devices = loadDevices();
1108
+ if (devices.length === 0) break;
970
1109
  const flags = [arg, ...rest].filter(Boolean);
971
1110
  const flagValue = (name) => {
972
1111
  const index = flags.indexOf(name);
@@ -980,7 +1119,7 @@ async function main() {
980
1119
  // the phone shows Allow/Deny; the printed JSON settles the
981
1120
  // permission. Timeout falls through to the normal terminal prompt.
982
1121
  if (flags.includes('--gate') && payload.hook_event_name === 'PreToolUse') {
983
- const decision = await requestApproval(config, {
1122
+ const decision = await requestApprovalAll(devices, {
984
1123
  source,
985
1124
  session: payload.session_id,
986
1125
  event: 'approval_request',
@@ -1032,7 +1171,7 @@ async function main() {
1032
1171
  const usage = claudeUsage(recompute);
1033
1172
  if (usage) message.usage = usage;
1034
1173
  }
1035
- await send(config, message);
1174
+ await sendAll(devices, message);
1036
1175
  } catch {
1037
1176
  // Never surface errors into the coding session.
1038
1177
  }
@@ -1041,14 +1180,14 @@ async function main() {
1041
1180
 
1042
1181
  case 'codex-notify': {
1043
1182
  try {
1044
- const config = loadConfig();
1045
- if (!config) break;
1183
+ const devices = loadDevices();
1184
+ if (devices.length === 0) break;
1046
1185
  const payload = JSON.parse(process.argv[process.argv.length - 1] || '{}');
1047
1186
  if (payload.type === 'agent-turn-complete') {
1048
1187
  const session = payload['conversation-id'] || payload.conversation_id;
1049
1188
  const message = { source: 'codex', session, event: 'turn_done' };
1050
1189
  if (payload.cwd) message.project = basename(payload.cwd);
1051
- await send(config, message);
1190
+ await sendAll(devices, message);
1052
1191
  }
1053
1192
  } catch {
1054
1193
  // Same rule as `hook`: stay silent.
@@ -1062,8 +1201,8 @@ async function main() {
1062
1201
  console.error(`Usage: carmoji test <${Object.keys(TEST_EVENTS).join('|')}> [session] [tokens]`);
1063
1202
  process.exit(1);
1064
1203
  }
1065
- const config = loadConfig();
1066
- if (!config) {
1204
+ const devices = loadDevices();
1205
+ if (devices.length === 0) {
1067
1206
  console.error('Not paired yet — run: carmoji pair <code>');
1068
1207
  process.exit(1);
1069
1208
  }
@@ -1073,9 +1212,12 @@ async function main() {
1073
1212
  if (project) message.project = project;
1074
1213
  const usage = claudeUsage(true);
1075
1214
  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);
1215
+ const results = await sendAll(devices, message);
1216
+ results.forEach((result, index) => {
1217
+ console.log(` ${deviceLabel(devices[index])}: ${
1218
+ result === 'ok' ? 'sent!' : `failed (${result})`}`);
1219
+ });
1220
+ process.exit(results.every((result) => result === 'ok') ? 0 : 1);
1079
1221
  break;
1080
1222
  }
1081
1223
 
@@ -1167,13 +1309,14 @@ async function main() {
1167
1309
  break;
1168
1310
 
1169
1311
  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');
1312
+ 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
1313
  }
1172
1314
  }
1173
1315
 
1174
1316
  export {
1175
1317
  applyTranscriptTokenRecord,
1176
1318
  eventFromHook,
1319
+ normalizeDevices,
1177
1320
  stripLegacyCarMojiNotifyLine,
1178
1321
  };
1179
1322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carmoji",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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": {