instar 0.3.5 → 0.3.6

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.
@@ -42,7 +42,8 @@ export declare function initProject(options: InitOptions): Promise<void>;
42
42
  * Refresh hooks, Claude settings, and CLAUDE.md for an existing installation.
43
43
  * Called after updates to ensure new hooks and documentation are installed.
44
44
  * Re-writes all hook files (idempotent), merges new hooks into settings,
45
- * and appends any missing sections to CLAUDE.md.
45
+ * appends any missing sections to CLAUDE.md, and installs scripts for
46
+ * configured integrations (e.g., Telegram relay).
46
47
  */
47
48
  export declare function refreshHooksAndSettings(projectDir: string, stateDir: string): void;
48
49
  export {};
@@ -636,13 +636,15 @@ If everything looks healthy, exit silently. Only report issues.`,
636
636
  * Refresh hooks, Claude settings, and CLAUDE.md for an existing installation.
637
637
  * Called after updates to ensure new hooks and documentation are installed.
638
638
  * Re-writes all hook files (idempotent), merges new hooks into settings,
639
- * and appends any missing sections to CLAUDE.md.
639
+ * appends any missing sections to CLAUDE.md, and installs scripts for
640
+ * configured integrations (e.g., Telegram relay).
640
641
  */
641
642
  export function refreshHooksAndSettings(projectDir, stateDir) {
642
643
  installHooks(stateDir);
643
644
  installClaudeSettings(projectDir);
644
645
  refreshClaudeMd(projectDir, stateDir);
645
646
  refreshJobs(stateDir);
647
+ refreshScripts(projectDir, stateDir);
646
648
  }
647
649
  /**
648
650
  * Merge new default jobs into existing jobs.json without overwriting user changes.
@@ -675,21 +677,116 @@ function refreshJobs(stateDir) {
675
677
  }
676
678
  catch { /* don't break on errors */ }
677
679
  }
680
+ /**
681
+ * Read config.json from state dir, returning parsed config or null.
682
+ */
683
+ function readConfig(stateDir) {
684
+ try {
685
+ return JSON.parse(fs.readFileSync(path.join(stateDir, 'config.json'), 'utf-8'));
686
+ }
687
+ catch {
688
+ return null;
689
+ }
690
+ }
691
+ /**
692
+ * Check if Telegram is configured in config.json.
693
+ */
694
+ function isTelegramConfigured(stateDir) {
695
+ const config = readConfig(stateDir);
696
+ if (!config)
697
+ return false;
698
+ const messaging = config.messaging;
699
+ return !!messaging?.some(m => m.type === 'telegram' && m.enabled);
700
+ }
701
+ /**
702
+ * Install scripts for configured integrations (e.g., Telegram relay).
703
+ * Called during refresh to ensure scripts exist for all configured integrations.
704
+ */
705
+ function refreshScripts(projectDir, stateDir) {
706
+ const config = readConfig(stateDir);
707
+ if (!config)
708
+ return;
709
+ const port = config.port || 4040;
710
+ // Install telegram-reply.sh if Telegram is configured
711
+ if (isTelegramConfigured(stateDir)) {
712
+ installTelegramRelay(projectDir, port);
713
+ }
714
+ }
715
+ /**
716
+ * Install the Telegram relay script that Claude uses to send responses
717
+ * back to Telegram topics via the instar server API.
718
+ */
719
+ function installTelegramRelay(projectDir, port) {
720
+ const scriptsDir = path.join(projectDir, '.claude', 'scripts');
721
+ fs.mkdirSync(scriptsDir, { recursive: true });
722
+ const scriptContent = `#!/bin/bash
723
+ # telegram-reply.sh — Send a message back to a Telegram topic via instar server.
724
+ #
725
+ # Usage:
726
+ # .claude/scripts/telegram-reply.sh TOPIC_ID "message text"
727
+ # echo "message text" | .claude/scripts/telegram-reply.sh TOPIC_ID
728
+ # cat <<'EOF' | .claude/scripts/telegram-reply.sh TOPIC_ID
729
+ # Multi-line message here
730
+ # EOF
731
+
732
+ TOPIC_ID="$1"
733
+ shift
734
+
735
+ if [ -z "$TOPIC_ID" ]; then
736
+ echo "Usage: telegram-reply.sh TOPIC_ID [message]" >&2
737
+ exit 1
738
+ fi
739
+
740
+ # Read message from args or stdin
741
+ if [ $# -gt 0 ]; then
742
+ MSG="$*"
743
+ else
744
+ MSG="$(cat)"
745
+ fi
746
+
747
+ if [ -z "$MSG" ]; then
748
+ echo "No message provided" >&2
749
+ exit 1
750
+ fi
751
+
752
+ PORT="\${INSTAR_PORT:-${port}}"
753
+
754
+ # Escape for JSON
755
+ JSON_MSG=$(printf '%s' "$MSG" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null)
756
+ if [ -z "$JSON_MSG" ]; then
757
+ # Fallback if python3 not available: basic escape
758
+ JSON_MSG="$(printf '%s' "$MSG" | sed 's/\\\\\\\\/\\\\\\\\\\\\\\\\/g; s/"/\\\\\\\\"/g' | sed ':a;N;$!ba;s/\\\\n/\\\\\\\\n/g')"
759
+ JSON_MSG="\\"$JSON_MSG\\""
760
+ fi
761
+
762
+ RESPONSE=$(curl -s -w "\\n%{http_code}" -X POST "http://localhost:\${PORT}/telegram/reply/\${TOPIC_ID}" \\
763
+ -H 'Content-Type: application/json' \\
764
+ -d "{\\"text\\":\${JSON_MSG}}")
765
+
766
+ HTTP_CODE=$(echo "$RESPONSE" | tail -1)
767
+ BODY=$(echo "$RESPONSE" | sed '$d')
768
+
769
+ if [ "$HTTP_CODE" = "200" ]; then
770
+ echo "Sent $(echo "$MSG" | wc -c | tr -d ' ') chars to topic $TOPIC_ID"
771
+ else
772
+ echo "Failed (HTTP $HTTP_CODE): $BODY" >&2
773
+ exit 1
774
+ fi
775
+ `;
776
+ const scriptPath = path.join(scriptsDir, 'telegram-reply.sh');
777
+ fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
778
+ }
678
779
  /**
679
780
  * Append missing sections to CLAUDE.md without overwriting user customizations.
680
781
  * Reads config.json for port, checks for known section headers, appends if missing.
782
+ * Also adds Telegram relay section if Telegram is configured.
681
783
  */
682
784
  function refreshClaudeMd(projectDir, stateDir) {
683
785
  const claudeMdPath = path.join(projectDir, 'CLAUDE.md');
684
786
  if (!fs.existsSync(claudeMdPath))
685
787
  return;
686
- // Read port from config
687
- let port = 4321;
688
- try {
689
- const config = JSON.parse(fs.readFileSync(path.join(stateDir, 'config.json'), 'utf-8'));
690
- port = config.port || 4321;
691
- }
692
- catch { /* use default port */ }
788
+ const config = readConfig(stateDir);
789
+ const port = config?.port || 4040;
693
790
  const content = fs.readFileSync(claudeMdPath, 'utf-8');
694
791
  const additions = [];
695
792
  // Check for Self-Diagnosis section
@@ -719,6 +816,22 @@ curl -s -X POST http://localhost:${port}/feedback \\
719
816
  Types: \`bug\`, \`feature\`, \`improvement\`, \`question\`
720
817
 
721
818
  **Do not wait for the user to notice.** If a hook throws an error, report it. If a job fails, report it. If the server returns unexpected data, report it. You are not just using instar — you are part of its immune system.
819
+ `);
820
+ }
821
+ // Check for Telegram Relay section (add if Telegram is configured)
822
+ if (isTelegramConfigured(stateDir) && !content.includes('Telegram Relay')) {
823
+ additions.push(`
824
+ ## Telegram Relay
825
+
826
+ When user input starts with \`[telegram:N]\`, the message came from a user via Telegram topic N. After responding, relay the response back:
827
+
828
+ \`\`\`bash
829
+ cat <<'EOF' | .claude/scripts/telegram-reply.sh N
830
+ Your response text here
831
+ EOF
832
+ \`\`\`
833
+
834
+ Strip the \`[telegram:N]\` prefix before interpreting the message. Only relay conversational text — not tool output.
722
835
  `);
723
836
  }
724
837
  if (additions.length > 0) {
@@ -50,20 +50,17 @@ async function respawnSessionForTopic(sessionManager, telegram, targetSession, t
50
50
  catch (err) {
51
51
  console.error(`[telegram→session] Failed to fetch thread history:`, err);
52
52
  }
53
- // Always keep the user's message inline never hide it behind a file reference.
54
- // Only thread history goes into a file for supplementary context.
53
+ // Single-line bootstrap to avoid tmux send-keys newline issues.
54
+ // Thread history and context go into temp files for Claude to read.
55
+ const tmpDir = '/tmp/instar-telegram';
56
+ fs.mkdirSync(tmpDir, { recursive: true });
55
57
  let bootstrapMessage;
56
58
  if (historyLines.length > 0) {
57
59
  const historyContent = historyLines.join('\n');
58
- const tmpDir = '/tmp/instar-telegram';
59
- fs.mkdirSync(tmpDir, { recursive: true });
60
60
  const filepath = path.join(tmpDir, `history-${topicId}-${Date.now()}-${process.pid}.txt`);
61
61
  fs.writeFileSync(filepath, historyContent);
62
- bootstrapMessage = [
63
- `[telegram:${topicId}] ${msg}`,
64
- ``,
65
- `(Session was respawned. Thread history is at ${filepath} — read it for context. Then RESPOND to the user's message above via Telegram relay.)`,
66
- ].join('\n');
62
+ // Single-line: user message + file reference for history
63
+ bootstrapMessage = `[telegram:${topicId}] ${msg} (Session respawned. Thread history at ${filepath} — read it for context before responding.)`;
67
64
  }
68
65
  else {
69
66
  bootstrapMessage = `[telegram:${topicId}] ${msg}`;
@@ -125,15 +122,22 @@ function wireTelegramRouting(telegram, sessionManager) {
125
122
  // No session mapped — auto-spawn one
126
123
  console.log(`[telegram→session] No session for topic ${topicId}, auto-spawning...`);
127
124
  const storedName = telegram.getTopicName(topicId) || `topic-${topicId}`;
128
- // Always keep the user's message inline never hide it behind a file reference
129
- const bootstrapMessage = [
130
- `[telegram:${topicId}] ${text}`,
131
- ``,
132
- `(This session was auto-created for a Telegram topic. Respond to the user's message above via Telegram relay.)`,
133
- ].join('\n');
125
+ // Single-line bootstrap to avoid tmux send-keys newline issues.
126
+ // Multi-line context is written to a temp file for Claude to read.
127
+ const contextLines = [
128
+ `This session was auto-created for Telegram topic ${topicId}.`,
129
+ `Respond to the user's message via Telegram relay: cat <<'EOF' | .claude/scripts/telegram-reply.sh ${topicId}`,
130
+ `Your response here`,
131
+ `EOF`,
132
+ ];
133
+ const tmpDir = '/tmp/instar-telegram';
134
+ fs.mkdirSync(tmpDir, { recursive: true });
135
+ const ctxPath = path.join(tmpDir, `ctx-${topicId}-${Date.now()}.txt`);
136
+ fs.writeFileSync(ctxPath, contextLines.join('\n'));
137
+ const bootstrapMessage = `[telegram:${topicId}] ${text}`;
134
138
  sessionManager.spawnInteractiveSession(bootstrapMessage, storedName).then((newSessionName) => {
135
139
  telegram.registerTopicSession(topicId, newSessionName);
136
- telegram.sendToTopic(topicId, `Session auto-created. I'm here.`).catch(() => { });
140
+ telegram.sendToTopic(topicId, `Session created.`).catch(() => { });
137
141
  console.log(`[telegram→session] Auto-spawned "${newSessionName}" for topic ${topicId}`);
138
142
  }).catch((err) => {
139
143
  console.error(`[telegram→session] Auto-spawn failed:`, err);
@@ -88,11 +88,14 @@ export declare class SessionManager extends EventEmitter {
88
88
  injectTelegramMessage(tmuxSession: string, topicId: number, text: string): void;
89
89
  /**
90
90
  * Send text to a tmux session via send-keys.
91
- * Uses -l (literal) flag for text, then sends Enter separately.
91
+ * For single-line text, uses -l (literal) flag directly.
92
+ * For multi-line text, writes to a temp file and uses tmux load-buffer/paste-buffer
93
+ * to avoid newlines being interpreted as Enter keypresses.
92
94
  */
93
95
  private injectMessage;
94
96
  /**
95
97
  * Wait for Claude to be ready in a tmux session by polling output.
98
+ * Looks for Claude Code's prompt character (❯) which appears when ready for input.
96
99
  */
97
100
  private waitForClaudeReady;
98
101
  private tmuxSessionExists;
@@ -314,9 +314,15 @@ export class SessionManager extends EventEmitter {
314
314
  this.waitForClaudeReady(tmuxSession).then((ready) => {
315
315
  if (ready) {
316
316
  this.injectMessage(tmuxSession, initialMessage);
317
+ console.log(`[SessionManager] Injected initial message into "${tmuxSession}" (${initialMessage.length} chars)`);
317
318
  }
318
319
  else {
319
- console.error(`[SessionManager] Claude not ready in session "${tmuxSession}" after timeout`);
320
+ console.error(`[SessionManager] Claude not ready in session "${tmuxSession}" message NOT injected. Session may need manual intervention.`);
321
+ // Still try to inject — Claude might be ready but prompt detection failed
322
+ if (this.tmuxSessionExists(tmuxSession)) {
323
+ console.log(`[SessionManager] Session "${tmuxSession}" still alive — attempting injection anyway`);
324
+ this.injectMessage(tmuxSession, initialMessage);
325
+ }
320
326
  }
321
327
  }).catch((err) => {
322
328
  console.error(`[SessionManager] Error waiting for Claude ready in "${tmuxSession}": ${err}`);
@@ -346,19 +352,50 @@ export class SessionManager extends EventEmitter {
346
352
  }
347
353
  /**
348
354
  * Send text to a tmux session via send-keys.
349
- * Uses -l (literal) flag for text, then sends Enter separately.
355
+ * For single-line text, uses -l (literal) flag directly.
356
+ * For multi-line text, writes to a temp file and uses tmux load-buffer/paste-buffer
357
+ * to avoid newlines being interpreted as Enter keypresses.
350
358
  */
351
359
  injectMessage(tmuxSession, text) {
352
360
  const exactTarget = `=${tmuxSession}:`;
353
361
  try {
354
- // Send the text literally
355
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '-l', text], {
356
- encoding: 'utf-8', timeout: 5000,
357
- });
358
- // Send Enter separately
359
- execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
360
- encoding: 'utf-8', timeout: 5000,
361
- });
362
+ if (text.includes('\n')) {
363
+ // Multi-line: write to temp file, load into tmux buffer, paste into pane.
364
+ // This avoids newlines being treated as Enter keypresses which would
365
+ // fragment the message into multiple Claude prompts.
366
+ const tmpDir = path.join('/tmp', 'instar-inject');
367
+ fs.mkdirSync(tmpDir, { recursive: true });
368
+ const tmpPath = path.join(tmpDir, `msg-${Date.now()}-${process.pid}.txt`);
369
+ fs.writeFileSync(tmpPath, text);
370
+ try {
371
+ execFileSync(this.config.tmuxPath, ['load-buffer', tmpPath], {
372
+ encoding: 'utf-8', timeout: 5000,
373
+ });
374
+ execFileSync(this.config.tmuxPath, ['paste-buffer', '-t', exactTarget, '-p'], {
375
+ encoding: 'utf-8', timeout: 5000,
376
+ });
377
+ // Send Enter to submit
378
+ execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
379
+ encoding: 'utf-8', timeout: 5000,
380
+ });
381
+ }
382
+ finally {
383
+ try {
384
+ fs.unlinkSync(tmpPath);
385
+ }
386
+ catch { /* ignore */ }
387
+ }
388
+ }
389
+ else {
390
+ // Single-line: simple send-keys
391
+ execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, '-l', text], {
392
+ encoding: 'utf-8', timeout: 5000,
393
+ });
394
+ // Send Enter separately
395
+ execFileSync(this.config.tmuxPath, ['send-keys', '-t', exactTarget, 'Enter'], {
396
+ encoding: 'utf-8', timeout: 5000,
397
+ });
398
+ }
362
399
  }
363
400
  catch (err) {
364
401
  console.error(`[SessionManager] Failed to inject message into ${tmuxSession}: ${err}`);
@@ -366,20 +403,29 @@ export class SessionManager extends EventEmitter {
366
403
  }
367
404
  /**
368
405
  * Wait for Claude to be ready in a tmux session by polling output.
406
+ * Looks for Claude Code's prompt character (❯) which appears when ready for input.
369
407
  */
370
- async waitForClaudeReady(tmuxSession, timeoutMs = 15000) {
408
+ async waitForClaudeReady(tmuxSession, timeoutMs = 30000) {
371
409
  const start = Date.now();
372
410
  // Wait a minimum startup delay before checking (Claude needs time to load)
373
- await new Promise(r => setTimeout(r, 2000));
411
+ await new Promise(r => setTimeout(r, 3000));
374
412
  while (Date.now() - start < timeoutMs) {
413
+ if (!this.tmuxSessionExists(tmuxSession)) {
414
+ console.error(`[SessionManager] Session "${tmuxSession}" died during startup`);
415
+ return false;
416
+ }
375
417
  const output = this.captureOutput(tmuxSession, 10);
376
418
  // Check for Claude Code's specific prompt character (❯)
377
419
  // Avoid matching generic shell prompts (> and $) which cause false positives
378
420
  if (output && output.includes('❯')) {
421
+ console.log(`[SessionManager] Claude ready in "${tmuxSession}" after ${Date.now() - start}ms`);
379
422
  return true;
380
423
  }
381
424
  await new Promise(r => setTimeout(r, 500));
382
425
  }
426
+ // Log what we see on timeout for debugging
427
+ const finalOutput = this.captureOutput(tmuxSession, 20);
428
+ console.error(`[SessionManager] Claude not ready in "${tmuxSession}" after ${timeoutMs}ms. Output: ${(finalOutput || '').slice(-200)}`);
383
429
  return false;
384
430
  }
385
431
  tmuxSessionExists(name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",