twinclaw 1.1.3 → 1.1.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.
package/dist/core/cli.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { execSync } from 'child_process';
2
+ import { readFileSync } from 'fs';
1
3
  import { runDoctorChecks, formatDoctorReport } from './doctor.js';
2
4
  // ── Help text ────────────────────────────────────────────────────────────────
3
5
  const HELP_TEXT = `
@@ -121,11 +123,11 @@ export function handleUnknownCommand(argv) {
121
123
  export function handleUpdateCli(argv) {
122
124
  if (argv[0] !== 'update')
123
125
  return false;
124
- const { execSync } = require('child_process');
125
126
  console.log('[TwinClaw] Checking for updates...');
126
127
  try {
127
128
  // Get current version
128
- const currentVersion = require('../../package.json').version;
129
+ const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
130
+ const currentVersion = packageJson.version;
129
131
  console.log(`[TwinClaw] Current version: ${currentVersion}`);
130
132
  // Fetch latest version from npm
131
133
  const latestVersion = execSync('npm view twinclaw version', { encoding: 'utf8' }).trim();
@@ -136,8 +138,8 @@ export function handleUpdateCli(argv) {
136
138
  return true;
137
139
  }
138
140
  console.log('[TwinClaw] Update available! Installing...');
139
- // Install latest version globally
140
- execSync('npm install -g twinclaw@latest', { stdio: 'inherit' });
141
+ // Install latest version globally (--yes to skip prompts)
142
+ execSync('npm install -g twinclaw@latest --yes', { stdio: 'inherit' });
141
143
  console.log(`[TwinClaw] Successfully updated to version ${latestVersion}`);
142
144
  console.log('[TwinClaw] Please restart TwinClaw to use the new version.');
143
145
  process.exitCode = 0;
@@ -11,6 +11,7 @@ const GATEWAY_COMMANDS = new Set([
11
11
  'status',
12
12
  'uninstall',
13
13
  'tailscale',
14
+ 'run',
14
15
  ]);
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = path.dirname(__filename);
@@ -54,8 +55,10 @@ export function parseGatewayArgs(argv) {
54
55
  if (argv[0] !== 'gateway') {
55
56
  throw new Error('Gateway parser expects argv beginning with "gateway".');
56
57
  }
58
+ // If no subcommand provided, default to 'run' (interactive mode)
59
+ const command = argv[1] || 'run';
57
60
  const parsed = {
58
- command: ensureCommand(argv[1]),
61
+ command: ensureCommand(command),
59
62
  asJson: false,
60
63
  deep: false,
61
64
  };
@@ -311,6 +314,24 @@ export async function handleGatewayCli(argv) {
311
314
  case 'tailscale':
312
315
  console.log(buildTailscaleInstructions(context).trimStart());
313
316
  break;
317
+ case 'run': {
318
+ // Run TwinClaw in foreground (interactive mode)
319
+ console.log('[TwinClaw] Starting TwinClaw in interactive mode...');
320
+ // Spawn the main application
321
+ const { spawn } = require('node:child_process');
322
+ const child = spawn(context.nodePath, [context.entryScript], {
323
+ stdio: 'inherit',
324
+ env: { ...process.env },
325
+ });
326
+ // Wait for the child to exit
327
+ await new Promise((resolve) => {
328
+ child.on('exit', (code) => {
329
+ process.exitCode = process.exitCode ?? code ?? 0;
330
+ resolve();
331
+ });
332
+ });
333
+ return true;
334
+ }
314
335
  }
315
336
  }
316
337
  catch (error) {
@@ -70,7 +70,7 @@ const WIZARD_SECTIONS = [
70
70
  {
71
71
  id: 'models',
72
72
  label: 'Intelligence & Models',
73
- fields: ['OPENROUTER_API_KEY', 'MODAL_API_KEY', 'GEMINI_API_KEY', 'GROQ_API_KEY'],
73
+ fields: ['PRIMARY_MODEL', 'OPENROUTER_API_KEY', 'MODAL_API_KEY', 'GEMINI_API_KEY', 'GROQ_API_KEY'],
74
74
  },
75
75
  {
76
76
  id: 'messaging',
@@ -105,6 +105,18 @@ const ONBOARD_FIELDS = [
105
105
  required: true,
106
106
  secret: true,
107
107
  },
108
+ {
109
+ key: 'PRIMARY_MODEL',
110
+ label: 'Primary Model',
111
+ hint: 'Required. Model to use as primary (e.g., openai/gpt-4o, anthropic/claude-sonnet-4-20250514).',
112
+ required: true,
113
+ validator: (value) => {
114
+ if (!value.includes('/')) {
115
+ return 'PRIMARY_MODEL must be in format "provider/model" (e.g., openai/gpt-4o).';
116
+ }
117
+ return null;
118
+ },
119
+ },
108
120
  {
109
121
  key: 'OPENROUTER_API_KEY',
110
122
  label: 'OpenRouter API Key',
@@ -229,18 +241,52 @@ class ReadlinePrompter {
229
241
  }
230
242
  async prompt(question, options = {}) {
231
243
  const isSecret = options.secret === true;
244
+ // For secret prompts, use a simpler approach without MutedWriter complexity
232
245
  if (isSecret) {
233
- process.stdout.write(question);
234
- this.#writer.muted = true;
246
+ return new Promise((resolve, reject) => {
247
+ this.#pendingReject = reject;
248
+ // Write question to stdout directly
249
+ process.stdout.write(question);
250
+ // Use raw mode to prevent echo
251
+ const originalRaw = process.stdin.isRaw;
252
+ process.stdin.setRawMode?.(true);
253
+ let input = '';
254
+ const onData = (chunk) => {
255
+ const char = chunk.toString();
256
+ if (char === '\r' || char === '\n') {
257
+ // Enter pressed - finish input
258
+ process.stdin.removeListener('data', onData);
259
+ process.stdin.setRawMode?.(originalRaw);
260
+ process.stdout.write('\n');
261
+ this.#pendingReject = null;
262
+ resolve(input.trim());
263
+ }
264
+ else if (char === '\u0003') {
265
+ // Ctrl+C
266
+ process.stdin.removeListener('data', onData);
267
+ process.stdin.setRawMode?.(originalRaw);
268
+ reject(new OnboardingCancelledError());
269
+ }
270
+ else if (char === '\u007f') {
271
+ // Backspace
272
+ if (input.length > 0) {
273
+ input = input.slice(0, -1);
274
+ process.stdout.write('\b \b');
275
+ }
276
+ }
277
+ else if (char.length === 1 && char.charCodeAt(0) >= 32) {
278
+ // Printable character
279
+ input += char;
280
+ process.stdout.write('*');
281
+ }
282
+ };
283
+ process.stdin.on('data', onData);
284
+ });
235
285
  }
236
286
  return new Promise((resolve, reject) => {
237
287
  this.#pendingReject = reject;
238
- this.#rl.question(isSecret ? '' : question, (answer) => {
288
+ this.#rl.question(question, (answer) => {
239
289
  this.#pendingReject = null;
240
- this.#writer.muted = false;
241
- if (isSecret) {
242
- process.stdout.write('\n');
243
- }
244
290
  resolve(answer.trim());
245
291
  });
246
292
  });
@@ -278,6 +324,8 @@ function readConfigValue(config, key) {
278
324
  return config.integration.embeddingProvider ?? '';
279
325
  case 'API_PORT':
280
326
  return String(config.runtime.apiPort ?? 3100);
327
+ case 'PRIMARY_MODEL':
328
+ return config.models.primaryModel ?? '';
281
329
  }
282
330
  }
283
331
  function applyConfigValue(config, key, value) {
@@ -303,9 +351,23 @@ function applyConfigValue(config, key, value) {
303
351
  case 'TELEGRAM_USER_ID':
304
352
  config.messaging.telegram.userId =
305
353
  value.trim().length === 0 ? null : Number.parseInt(value.trim(), 10);
354
+ // Also add to allowlist so user can message themselves
355
+ if (config.messaging.telegram.userId !== null) {
356
+ const userIdStr = String(config.messaging.telegram.userId);
357
+ if (!config.messaging.telegram.allowFrom.includes(userIdStr)) {
358
+ config.messaging.telegram.allowFrom.push(userIdStr);
359
+ }
360
+ }
306
361
  break;
307
362
  case 'WHATSAPP_PHONE_NUMBER':
308
363
  config.messaging.whatsapp.phoneNumber = value;
364
+ // Also add to allowlist so user can message themselves
365
+ if (value && value.trim().length > 0) {
366
+ const normalizedPhone = value.replace(/\D/g, ''); // Remove non-digits
367
+ if (!config.messaging.whatsapp.allowFrom.includes(normalizedPhone)) {
368
+ config.messaging.whatsapp.allowFrom.push(normalizedPhone);
369
+ }
370
+ }
309
371
  break;
310
372
  case 'EMBEDDING_PROVIDER':
311
373
  config.integration.embeddingProvider =
@@ -314,6 +376,9 @@ function applyConfigValue(config, key, value) {
314
376
  case 'API_PORT':
315
377
  config.runtime.apiPort = Number.parseInt(value, 10);
316
378
  break;
379
+ case 'PRIMARY_MODEL':
380
+ config.models.primaryModel = value;
381
+ break;
317
382
  }
318
383
  }
319
384
  function applyUpdates(config, updates) {
@@ -283,7 +283,7 @@ Multi-Modal Hooks | Proactive Memory | Voice-First
283
283
  : [];
284
284
  }
285
285
  config.messaging = config.messaging || {
286
- telegram: { enabled: false, botToken: '', userId: null, dmPolicy: 'pairing', groupPolicy: 'allowlist', allowFrom: [] },
286
+ telegram: { enabled: false, botToken: '', userId: null, dmPolicy: 'allowlist', groupPolicy: 'allowlist', allowFrom: [] },
287
287
  whatsapp: { enabled: false, phoneNumber: '', dmPolicy: 'allowlist', groupPolicy: 'allowlist', allowFrom: [] },
288
288
  voice: { groqApiKey: '' },
289
289
  inbound: { enabled: false, debounceMs: 0 },
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
3
  const require = createRequire(import.meta.url);
4
- import { handleOnboardCli, runOnboarding, runSetupWizard, runSimplifiedOnboarding, startBasicREPL } from './core/onboarding.js';
4
+ import { handleOnboardCli, runOnboarding, runSetupWizard, startBasicREPL } from './core/onboarding.js';
5
5
  import { Gateway } from './core/gateway.js';
6
6
  import { handleDoctorCli, handleHelpCli, handleUnknownCommand, handleUpdateCli } from './core/cli.js';
7
7
  import { handleSelfImproveCli } from './core/self-improve-cli.js';
@@ -111,7 +111,7 @@ async function tryAutoSetup() {
111
111
  catch (error) {
112
112
  console.log("\n[TwinClaw] Welcome! Critical configuration missing.");
113
113
  console.log("Starting the interactive setup wizard to configure your agent.\n");
114
- await runSimplifiedOnboarding();
114
+ await runOnboarding();
115
115
  await runSetupWizard();
116
116
  console.log("\n[TwinClaw] Setup complete. Initializing Gateway...\n");
117
117
  }
@@ -264,15 +264,24 @@ if (telegramBotToken || whatsappPhoneNumber) {
264
264
  }
265
265
  }, heartbeat.scheduler);
266
266
  queueService.start();
267
+ // Read from environment variables or default to 'allowlist'
268
+ const telegramDmPolicy = (process.env.TELEGRAM_DM_POLICY || 'allowlist');
269
+ const whatsappDmPolicy = (process.env.WHATSAPP_DM_POLICY || 'allowlist');
270
+ const telegramGroupPolicy = (process.env.TELEGRAM_GROUP_POLICY || 'allowlist');
271
+ const whatsappGroupPolicy = (process.env.WHATSAPP_GROUP_POLICY || 'allowlist');
267
272
  dispatcher = new Dispatcher(telegramHandler, whatsappHandler, sttService, ttsService, gateway, queueService, {
268
273
  pairingService,
269
274
  telegram: {
270
- dmPolicy: 'allowlist',
275
+ dmPolicy: telegramDmPolicy,
276
+ groupPolicy: telegramGroupPolicy,
271
277
  allowFrom: telegramAllowFrom,
278
+ groupAllowFrom: telegramAllowFrom, // Use same allowlist for groups by default
272
279
  },
273
280
  whatsapp: {
274
- dmPolicy: 'allowlist',
281
+ dmPolicy: whatsappDmPolicy,
282
+ groupPolicy: whatsappGroupPolicy,
275
283
  allowFrom: whatsappAllowFrom,
284
+ groupAllowFrom: whatsappAllowFrom, // Use same allowlist for groups by default
276
285
  },
277
286
  });
278
287
  void logThought('[TwinClaw] Messaging dispatcher and persistent queue initialized.');
@@ -6,8 +6,17 @@ import { EmbeddedBlockChunker } from '../services/block-chunker.js';
6
6
  import { getConfigValue } from '../config/config-loader.js';
7
7
  const DEFAULT_ACCESS_CONFIG = {
8
8
  dmPolicy: 'allowlist',
9
+ groupPolicy: 'allowlist',
9
10
  allowFrom: [],
10
11
  };
12
+ function buildGroupRejectionMessage(channel) {
13
+ return (`[TwinClaw] Your message from a group was not processed because group messages are not allowed for ${channel}.\n` +
14
+ `Please contact the bot owner to request access.`);
15
+ }
16
+ function buildAllowlistRejectionMessage(channel) {
17
+ return (`[TwinClaw] Your message was not processed because you are not on the allowed list for ${channel}.\n` +
18
+ `Please contact the bot owner to request access.`);
19
+ }
11
20
  function buildPairingChallenge(channel, code) {
12
21
  return (`[TwinClaw] Pairing required before I can process your messages on ${channel}.\n` +
13
22
  `Run: twinclaw pairing approve ${channel} ${code}`);
@@ -113,12 +122,18 @@ export class Dispatcher {
113
122
  }
114
123
  }
115
124
  #resolveAccessConfig(channel, config) {
116
- const dmPolicy = config?.dmPolicy === 'allowlist' ? 'allowlist' : 'pairing';
125
+ // Default to 'allowlist' - never default to 'pairing' for security
126
+ const dmPolicy = config?.dmPolicy === 'pairing' ? 'pairing' : 'allowlist';
127
+ const groupPolicy = config?.groupPolicy ?? 'allowlist';
117
128
  const allowFrom = [...new Set((config?.allowFrom ?? DEFAULT_ACCESS_CONFIG.allowFrom)
118
129
  .map((senderId) => normalizePairingSenderId(channel, senderId))
119
130
  .filter((senderId) => senderId.length > 0))];
131
+ const groupAllowFrom = [...new Set((config?.groupAllowFrom ?? [])
132
+ .map((senderId) => normalizePairingSenderId(channel, senderId))
133
+ .filter((senderId) => senderId.length > 0))];
120
134
  this.#pairingService.seedAllowFrom(channel, allowFrom);
121
- return { dmPolicy, allowFrom };
135
+ this.#pairingService.seedAllowFrom(channel, groupAllowFrom);
136
+ return { dmPolicy, groupPolicy, allowFrom, groupAllowFrom };
122
137
  }
123
138
  #authorizeSender(message) {
124
139
  const channel = message.platform;
@@ -127,12 +142,36 @@ export class Dispatcher {
127
142
  return { allowed: false };
128
143
  }
129
144
  const config = this.#accessConfig[message.platform];
130
- const allowlist = new Set(config.allowFrom);
145
+ const isGroupChat = message.isGroupChat ?? false;
146
+ // Determine which allowlist to use based on group/dm
147
+ const allowlist = isGroupChat
148
+ ? new Set(config.groupAllowFrom ?? [])
149
+ : new Set(config.allowFrom);
150
+ // Check if sender is already allowed
131
151
  if (allowlist.has(normalizedSenderId) || this.#pairingService.isApproved(channel, normalizedSenderId)) {
132
152
  return { allowed: true };
133
153
  }
154
+ // Apply group policy if this is a group chat
155
+ if (isGroupChat) {
156
+ if (config.groupPolicy === 'open') {
157
+ return { allowed: true };
158
+ }
159
+ if (config.groupPolicy === 'denylist') {
160
+ // Check if sender is on denylist (for future implementation)
161
+ return { allowed: true }; // Allow by default for now, denylist check can be added later
162
+ }
163
+ // groupPolicy === 'allowlist' - sender not in groupAllowFrom, reject
164
+ return {
165
+ allowed: false,
166
+ challengeText: buildGroupRejectionMessage(channel),
167
+ };
168
+ }
169
+ // DM policy check for non-group chats
134
170
  if (config.dmPolicy !== 'pairing') {
135
- return { allowed: false };
171
+ return {
172
+ allowed: false,
173
+ challengeText: buildAllowlistRejectionMessage(channel),
174
+ };
136
175
  }
137
176
  const request = this.#pairingService.requestPairing(channel, normalizedSenderId);
138
177
  if (request.status === 'created' && request.request) {
@@ -34,10 +34,13 @@ export class TelegramHandler {
34
34
  if (!msg.from)
35
35
  return;
36
36
  await this.#applyRateLimit();
37
+ // Detect if this is a group chat (group or supergroup)
38
+ const isGroupChat = msg.chat.type === 'group' || msg.chat.type === 'supergroup';
37
39
  const base = {
38
40
  platform: 'telegram',
39
41
  senderId: String(msg.from.id),
40
42
  chatId: msg.chat.id,
43
+ isGroupChat,
41
44
  text: msg.text,
42
45
  rawPayload: msg,
43
46
  };
@@ -54,10 +57,13 @@ export class TelegramHandler {
54
57
  }
55
58
  catch (err) {
56
59
  console.error('[TelegramHandler] Failed to download voice note:', err);
60
+ // Continue to process text below instead of returning
57
61
  }
58
- return;
59
62
  }
60
- await this.onMessage?.(base);
63
+ // Process text - this runs whether voice succeeded or failed
64
+ if (msg.text || base.text) {
65
+ await this.onMessage?.({ ...base, text: msg.text || base.text });
66
+ }
61
67
  });
62
68
  this.#bot.on('polling_error', (err) => {
63
69
  console.error('[TelegramHandler] Polling error:', err.message);
@@ -116,10 +116,13 @@ export class WhatsAppHandler {
116
116
  // Incoming messages
117
117
  this.#client.on('message', async (msg) => {
118
118
  await this.#applyRateLimit();
119
+ // Detect if this is a group chat (group chats have @g.us in the ID)
120
+ const isGroupChat = msg.from.includes('@g.us');
119
121
  const base = {
120
122
  platform: 'whatsapp',
121
123
  senderId: msg.from,
122
124
  chatId: msg.from,
125
+ isGroupChat,
123
126
  text: msg.body,
124
127
  rawPayload: msg,
125
128
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twinclaw",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Eagle-eyed agentic AI gateway with multi-modal hooks and proactive memory.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {