instar 1.3.879 → 1.3.880

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.
@@ -18,6 +18,24 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
18
  // (A.5) wake-handler amplifier guard — the post-wake tmux re-validation is bounded
19
19
  // async (9s + SIGKILL) instead of a sync execFileSync that can wedge the event loop.
20
20
  const execFileAsync = promisify(execFile);
21
+ function packagedSlackReplyRelay() {
22
+ for (const candidate of [
23
+ path.resolve(__dirname, '..', 'templates', 'scripts', 'slack-reply.sh'),
24
+ path.resolve(__dirname, '..', '..', 'src', 'templates', 'scripts', 'slack-reply.sh'),
25
+ ]) {
26
+ try {
27
+ return fs.readFileSync(candidate, 'utf8');
28
+ }
29
+ catch { /* try packaged/dev alternative */ }
30
+ }
31
+ return null;
32
+ }
33
+ function localSlackRelayReadiness(stateDir) {
34
+ const template = packagedSlackReplyRelay();
35
+ if (!template)
36
+ return { ready: false, reason: 'packaged Slack reply relay template is unavailable' };
37
+ return slackReplyRelayReadiness(stateDir, template);
38
+ }
21
39
  import { loadConfig, ensureStateDir, detectTmuxPath, detectGeminiPath } from '../core/Config.js';
22
40
  import { handleProcessLevelError } from '../core/uncaughtExceptionPolicy.js';
23
41
  import { planInboundLossNotices } from '../core/inboundLossRouting.js';
@@ -33,6 +51,7 @@ import { resolveAntiThrashKnobs, readingValidity } from '../core/SwapAntiThrash.
33
51
  import { shouldReleaseOnComplete, planClaimOnSpawn, ownershipNonce } from '../core/ownershipFollowsLiveWork.js';
34
52
  import { PrHandLease } from '../core/PrHandLease.js';
35
53
  import { ConversationRegistry } from '../core/ConversationRegistry.js';
54
+ import { slackReplyRelayReadiness } from '../core/SlackReplyRelayInstaller.js';
36
55
  import { claimSuspensionExcludesPin } from '../core/TopicClaimAnnotationStore.js';
37
56
  import { parseProfileTrigger, platformMessageIdFrom } from '../core/topicProfileIngress.js';
38
57
  import { classifyProfileIntent, toProfilePatch, } from '../core/ProfileIntentClassifier.js';
@@ -7614,7 +7633,6 @@ export async function startServer(options) {
7614
7633
  // CONSUMES it rather than re-resolving.
7615
7634
  admissionVerdict) => {
7616
7635
  const channelId = message.channel.identifier;
7617
- const isDM = message.metadata?.isDM;
7618
7636
  const senderName = message.metadata?.senderName || 'User';
7619
7637
  // ── Thread → session routing (§5.3) ──────────────────────────────────
7620
7638
  // The Slack channelId is ALWAYS the address we talk to the Slack API with
@@ -7692,15 +7710,14 @@ export async function startServer(options) {
7692
7710
  contextLines.push('CRITICAL: You MUST relay your response back to Slack after responding.');
7693
7711
  contextLines.push('Use the relay script (write ONLY your reply text — do NOT pipe or cat this file into the script):');
7694
7712
  contextLines.push('');
7695
- // Thread session: pass the thread_ts as the 2nd arg so the reply lands IN
7696
- // the thread (not the channel root). Channel session: channelId only.
7697
- const replyTarget = replyThreadTs ? `${channelId} ${replyThreadTs}` : `${channelId}`;
7698
- contextLines.push(`cat <<'EOF' | .claude/scripts/slack-reply.sh ${replyTarget}`);
7713
+ // The bind-token-scoped conversation id is the destination authority;
7714
+ // never interpolate a caller-chosen channel/thread into the command.
7715
+ contextLines.push("cat <<'EOF' | .instar/scripts/slack-reply.sh");
7699
7716
  contextLines.push('Your response text here');
7700
7717
  contextLines.push('EOF');
7701
7718
  if (replyThreadTs) {
7702
7719
  contextLines.push('');
7703
- contextLines.push('(This is a THREAD conversation — keep your reply in this thread by passing the thread id shown above as the 2nd argument.)');
7720
+ contextLines.push('(This is a THREAD conversation — the session binding keeps the reply in this exact thread.)');
7704
7721
  }
7705
7722
  contextLines.push('');
7706
7723
  contextLines.push('Strip the [slack:] prefix before interpreting the message.');
@@ -7790,10 +7807,22 @@ export async function startServer(options) {
7790
7807
  if (resumeInfo) {
7791
7808
  slackAdapter.removeChannelResume(routingKey);
7792
7809
  }
7793
- // Route: DMs go to lifeline session, channels/threads spawn new sessions.
7794
- // A thread session NEVER folds into the DM lifeline — it is its own
7795
- // isolated work session (DMs don't carry thread_ts anyway).
7796
- const targetSession = (isDM && !isThreadSession) ? 'lifeline' : undefined;
7810
+ // Readiness gates actual spawn only. Existing live sessions keep
7811
+ // receiving inbound work even if a later filesystem drift is found;
7812
+ // their next reply reports the helper failure rather than dropping
7813
+ // the user's message before injection.
7814
+ const relayReadiness = localSlackRelayReadiness(config.stateDir);
7815
+ if (conversationId === null || !relayReadiness.ready) {
7816
+ const reason = conversationId === null
7817
+ ? 'conversation identity unavailable'
7818
+ : (!relayReadiness.ready ? relayReadiness.reason : 'unknown readiness failure');
7819
+ console.error(`[slack→session] spawn refused: slack-relay-not-ready (${reason})`);
7820
+ return;
7821
+ }
7822
+ // Slack DMs are isolated 1:1 bound sessions too. A shared lifeline
7823
+ // intentionally has no single INSTAR_CONVERSATION_ID, so routing DMs
7824
+ // there would make a destination-free reply impossible to authorize.
7825
+ const targetSession = undefined;
7797
7826
  // ── SpawnAdmission (ownership-gated-spawn §3.1) — the Slack inbound
7798
7827
  // spawn callsite. Refusal floor: durable-queue custody where live;
7799
7828
  // Slack has no telegram-topic notice surface, so the ladder records
@@ -8020,10 +8049,16 @@ export async function startServer(options) {
8020
8049
  slackAdapter.onStallDetected = (channelId, sessionName, messageText, injectedAt) => {
8021
8050
  const slackAttentionChannel = state.get('slack-attention-channel');
8022
8051
  if (slackAttentionChannel) {
8023
- slackAdapter.sendToChannel(slackAttentionChannel, `⚠️ Stall detected in session "${sessionName}" (channel ${channelId}). Message may not have been answered: "${messageText.slice(0, 100)}"`).catch(() => { });
8052
+ slackAdapter.sendToChannel(slackAttentionChannel, `⚠️ Stall detected in session "${sessionName}" (channel ${channelId}). Message may not have been answered: "${messageText.slice(0, 100)}"`).catch(err => {
8053
+ // @silent-fallback-ok best-effort advisory; failure is surfaced in server logs
8054
+ console.warn(`[slack] Failed to send stall notice to attention channel: ${err instanceof Error ? err.message : String(err)}`);
8055
+ });
8024
8056
  }
8025
8057
  // Also notify in the originating channel
8026
- slackAdapter.sendToChannel(channelId, `⚠️ The session appears to have stalled. Use \`!restart\` to restart or \`!interrupt\` to nudge it.`).catch(() => { });
8058
+ slackAdapter.sendToChannel(channelId, `⚠️ The session appears to have stalled. Use \`!restart\` to restart or \`!interrupt\` to nudge it.`).catch(err => {
8059
+ // @silent-fallback-ok best-effort advisory; failure is surfaced in server logs
8060
+ console.warn(`[slack] Failed to send stall notice to originating channel: ${err instanceof Error ? err.message : String(err)}`);
8061
+ });
8027
8062
  };
8028
8063
  // Wire voice transcription (reuses Telegram's provider resolution: Groq → OpenAI)
8029
8064
  slackAdapter.transcribeVoice = async (filePath) => {
@@ -10317,6 +10352,11 @@ export async function startServer(options) {
10317
10352
  const parsedTarget = _slackAdapter.parseRoutingKey(slackChId);
10318
10353
  const slackApiChannel = parsedTarget.channelId;
10319
10354
  const slackReplyThread = parsedTarget.threadTs;
10355
+ const relayReadiness = localSlackRelayReadiness(config.stateDir);
10356
+ if (!relayReadiness.ready) {
10357
+ console.error(`[slack→recovery] spawn refused: slack-relay-not-ready (${relayReadiness.reason})`);
10358
+ return;
10359
+ }
10320
10360
  // Kill existing session (already flagged in contextExhaustionKills via event listener)
10321
10361
  const session = sessionManager.listRunningSessions().find(s => s.tmuxSession === _sessionName);
10322
10362
  if (session)
@@ -10353,9 +10393,9 @@ export async function startServer(options) {
10353
10393
  }
10354
10394
  lines.push('');
10355
10395
  lines.push('CRITICAL: You MUST relay your response back to Slack.');
10356
- // Thread session: include the thread_ts so the recovered reply threads.
10357
- const recoveryReplyTarget = slackReplyThread ? `${slackApiChannel} ${slackReplyThread}` : `${slackApiChannel}`;
10358
- lines.push(`cat <<'EOF' | .claude/scripts/slack-reply.sh ${recoveryReplyTarget}`);
10396
+ // The recovered session receives a freshly minted bind token;
10397
+ // destination selection stays server-side and thread-exact.
10398
+ lines.push("cat <<'EOF' | .instar/scripts/slack-reply.sh");
10359
10399
  lines.push('Your response text here');
10360
10400
  lines.push('EOF');
10361
10401
  const tmpDir = '/tmp/instar-slack';