instar 1.3.444 → 1.3.446
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/README.md +9 -1
- package/dist/commands/server.d.ts +20 -1
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +121 -50
- package/dist/commands/server.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +14 -4
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +1 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +8 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/messaging/slack/SlackAdapter.d.ts +54 -2
- package/dist/messaging/slack/SlackAdapter.d.ts.map +1 -1
- package/dist/messaging/slack/SlackAdapter.js +88 -6
- package/dist/messaging/slack/SlackAdapter.js.map +1 -1
- package/dist/messaging/slack/types.d.ts +29 -0
- package/dist/messaging/slack/types.d.ts.map +1 -1
- package/dist/messaging/slack/types.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +9 -2
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +65 -65
- package/src/templates/scripts/slack-reply.sh +26 -1
- package/upgrades/1.3.445.md +99 -0
- package/upgrades/1.3.446.md +24 -0
- package/upgrades/side-effects/fixcommand-gate-nonattention-fallthrough.md +93 -0
- package/upgrades/side-effects/slack-thread-session.md +48 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
|
-
<a href="https://www.npmjs.com/package/instar">npm</a> · <a href="https://github.com/JKHeadley/instar">GitHub</a> · <a href="https://instar.sh">instar.sh</a> · <a href="https://instar.sh/introduction/">Docs</a>
|
|
21
|
+
<a href="https://www.npmjs.com/package/instar">npm</a> · <a href="https://github.com/JKHeadley/instar">GitHub</a> · <a href="https://instar.sh">instar.sh</a> · <a href="https://instar.sh/introduction/">Docs</a> · <a href="https://instar.sh/exo3">EXO 3.0</a>
|
|
22
22
|
</p>
|
|
23
23
|
|
|
24
24
|
---
|
|
@@ -116,6 +116,14 @@ An agent that forgets what you discussed yesterday, doesn't recognize someone it
|
|
|
116
116
|
|
|
117
117
|
> **Deep dive:** [The Coherence Problem](https://instar.sh/concepts/coherence/) · [Values & Identity](https://instar.sh/concepts/values/) · [Coherence Is Safety](https://instar.sh/concepts/safety/)
|
|
118
118
|
|
|
119
|
+
## EXO 3.0 — governed by your organization's intent
|
|
120
|
+
|
|
121
|
+
Instar independently converged on Salim Ismail's **EXO 3.0 / "The Organizational Singularity"** framework: the idea that an organization's purpose should be encoded as *machine-readable* intent — "in code, not culture" — that actually **governs** its agents rather than just inspiring them. Instar was built around coherence before the framework existed, and the overlap turned out to be close to the line.
|
|
122
|
+
|
|
123
|
+
We didn't just claim it works. We ran controlled experiments — same model, same requests, with the organization's intent switched off as the control — to show the intent itself is what changes the agent's behavior. The engine stays **neutral**: it enforces whatever intent a deploying organization gives it, never Instar's own values. Two case studies enforce two very different fictional companies' values equally well, neither of them ours.
|
|
124
|
+
|
|
125
|
+
> **See the controlled proof → [instar.sh/exo3](https://instar.sh/exo3)** — two governed-vs-ungoverned case studies, with full transcripts.
|
|
126
|
+
|
|
119
127
|
## Features
|
|
120
128
|
|
|
121
129
|
| Feature | Description | Docs |
|
|
@@ -12,6 +12,25 @@ import { TelegramAdapter } from '../messaging/TelegramAdapter.js';
|
|
|
12
12
|
import { TopicMemory } from '../memory/TopicMemory.js';
|
|
13
13
|
import { QuotaTracker } from '../monitoring/QuotaTracker.js';
|
|
14
14
|
import { UserManager } from '../users/UserManager.js';
|
|
15
|
+
/**
|
|
16
|
+
* Pure: should the emergency "fix command" gate intercept this message?
|
|
17
|
+
*
|
|
18
|
+
* Fix commands ("fix auth", "restart sessions", "clean processes", …) are
|
|
19
|
+
* mechanical server-side operations that only make sense in the Agent Attention
|
|
20
|
+
* topic, where the agent posts actionable notifications the user taps to resolve.
|
|
21
|
+
* In ANY other topic, a message that merely starts with "restart" / "fix " /
|
|
22
|
+
* "clean " is ordinary conversation ("restart the build", "fix the login page",
|
|
23
|
+
* "clean up this function") and must route to the session — never be swallowed.
|
|
24
|
+
*
|
|
25
|
+
* The previous logic ran this verb test in every topic and, on a non-attention
|
|
26
|
+
* topic, bounced the message back with "I didn't recognize that command" while
|
|
27
|
+
* also swallowing it (the gate `return`s). That is exactly why a user trying to
|
|
28
|
+
* revive a stuck session by typing "restart sessions" in that session's own
|
|
29
|
+
* topic never reached the session: the gate ate the message and replied with a
|
|
30
|
+
* help list that even advertised "restart sessions" as valid. Scoping the gate
|
|
31
|
+
* to the attention topic closes that hole.
|
|
32
|
+
*/
|
|
33
|
+
export declare function shouldInterceptFixCommand(text: string, topicId: number, attentionTopicId: number | null | undefined): boolean;
|
|
15
34
|
interface StartOptions {
|
|
16
35
|
foreground?: boolean;
|
|
17
36
|
dir?: string;
|
|
@@ -19,7 +38,7 @@ interface StartOptions {
|
|
|
19
38
|
* Commander maps --no-telegram to telegram: false. */
|
|
20
39
|
telegram?: boolean;
|
|
21
40
|
}
|
|
22
|
-
export declare function wireTelegramRouting(telegram: TelegramAdapter, sessionManager: SessionManager, quotaTracker?: QuotaTracker, topicMemory?: TopicMemory, userManager?: UserManager, fixCommandHandler?: (topicId: number, text: string) => Promise<boolean>, getHubDeps?: () => import('../threadline/hubCommands.js').HubBindDeps | null, getTopicOperatorStore?: () => import('../users/TopicOperatorStore.js').TopicOperatorStore | null): void;
|
|
41
|
+
export declare function wireTelegramRouting(telegram: TelegramAdapter, sessionManager: SessionManager, quotaTracker?: QuotaTracker, topicMemory?: TopicMemory, userManager?: UserManager, fixCommandHandler?: (topicId: number, text: string) => Promise<boolean>, getHubDeps?: () => import('../threadline/hubCommands.js').HubBindDeps | null, getTopicOperatorStore?: () => import('../users/TopicOperatorStore.js').TopicOperatorStore | null, getAttentionTopicId?: () => number | null | undefined): void;
|
|
23
42
|
export declare function startServer(options: StartOptions): Promise<void>;
|
|
24
43
|
export declare function stopServer(options: {
|
|
25
44
|
dir?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAS3D,OAAO,EAAE,eAAe,EAAiC,MAAM,iCAAiC,CAAC;AAuBjG,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAGvD,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAiG7D,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAS3D,OAAO,EAAE,eAAe,EAAiC,MAAM,iCAAiC,CAAC;AAuBjG,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAGvD,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAiG7D,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAsBtD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,gBAAgB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC1C,OAAO,CAUT;AAyID,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA05BD,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,eAAe,EACzB,cAAc,EAAE,cAAc,EAC9B,YAAY,CAAC,EAAE,YAAY,EAC3B,WAAW,CAAC,EAAE,WAAW,EACzB,WAAW,CAAC,EAAE,WAAW,EACzB,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,EAGvE,UAAU,CAAC,EAAE,MAAM,OAAO,8BAA8B,EAAE,WAAW,GAAG,IAAI,EAK5E,qBAAqB,CAAC,EAAE,MAAM,OAAO,gCAAgC,EAAE,kBAAkB,GAAG,IAAI,EAKhG,mBAAmB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,GACpD,IAAI,CA0UN;AA2lBD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAoyUtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
|
package/dist/commands/server.js
CHANGED
|
@@ -144,6 +144,34 @@ import { toInjection } from '../types/pipeline.js';
|
|
|
144
144
|
import { UserManager } from '../users/UserManager.js';
|
|
145
145
|
import { formatUserContextForSession, hasUserContext } from '../users/UserContextBuilder.js';
|
|
146
146
|
import { SafeFsExecutor } from '../core/SafeFsExecutor.js';
|
|
147
|
+
/**
|
|
148
|
+
* Pure: should the emergency "fix command" gate intercept this message?
|
|
149
|
+
*
|
|
150
|
+
* Fix commands ("fix auth", "restart sessions", "clean processes", …) are
|
|
151
|
+
* mechanical server-side operations that only make sense in the Agent Attention
|
|
152
|
+
* topic, where the agent posts actionable notifications the user taps to resolve.
|
|
153
|
+
* In ANY other topic, a message that merely starts with "restart" / "fix " /
|
|
154
|
+
* "clean " is ordinary conversation ("restart the build", "fix the login page",
|
|
155
|
+
* "clean up this function") and must route to the session — never be swallowed.
|
|
156
|
+
*
|
|
157
|
+
* The previous logic ran this verb test in every topic and, on a non-attention
|
|
158
|
+
* topic, bounced the message back with "I didn't recognize that command" while
|
|
159
|
+
* also swallowing it (the gate `return`s). That is exactly why a user trying to
|
|
160
|
+
* revive a stuck session by typing "restart sessions" in that session's own
|
|
161
|
+
* topic never reached the session: the gate ate the message and replied with a
|
|
162
|
+
* help list that even advertised "restart sessions" as valid. Scoping the gate
|
|
163
|
+
* to the attention topic closes that hole.
|
|
164
|
+
*/
|
|
165
|
+
export function shouldInterceptFixCommand(text, topicId, attentionTopicId) {
|
|
166
|
+
if (!attentionTopicId || topicId !== attentionTopicId)
|
|
167
|
+
return false;
|
|
168
|
+
const cmd = text.trim().toLowerCase();
|
|
169
|
+
return (cmd.startsWith('fix ') ||
|
|
170
|
+
cmd.startsWith('clean ') ||
|
|
171
|
+
cmd.startsWith('restart') ||
|
|
172
|
+
cmd === 'fix' ||
|
|
173
|
+
cmd === 'clean');
|
|
174
|
+
}
|
|
147
175
|
/**
|
|
148
176
|
* Handle "fix X" and "clean X" commands from Agent Attention notifications.
|
|
149
177
|
* These are mechanical server-side operations — no Claude session needed.
|
|
@@ -1100,7 +1128,12 @@ getHubDeps,
|
|
|
1100
1128
|
// the SAME instance the routes use (a second instance on the same file would
|
|
1101
1129
|
// lose updates between the two in-memory caches). Know Your Principal #898,
|
|
1102
1130
|
// increment 2e: the polling-path operator auto-bind.
|
|
1103
|
-
getTopicOperatorStore
|
|
1131
|
+
getTopicOperatorStore,
|
|
1132
|
+
// Late-bound resolver for the Agent Attention topic id. The emergency
|
|
1133
|
+
// fix-command gate (below) only fires in that topic; everywhere else a
|
|
1134
|
+
// message starting with restart/fix/clean is normal conversation and must
|
|
1135
|
+
// route to the session instead of being swallowed.
|
|
1136
|
+
getAttentionTopicId) {
|
|
1104
1137
|
// Guard: tracks which topic IDs have a spawn in progress.
|
|
1105
1138
|
// Prevents duplicate concurrent spawns for the same topic when messages
|
|
1106
1139
|
// arrive faster than the async spawn completes.
|
|
@@ -1214,34 +1247,35 @@ getTopicOperatorStore) {
|
|
|
1214
1247
|
// ── Fix commands from notification messages ──────────────────────
|
|
1215
1248
|
// Handle "fix auth", "clean processes", "restart", etc. directly
|
|
1216
1249
|
// in the server process — no need to spawn a Claude session for these.
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
catch (err) {
|
|
1239
|
-
console.error(`[telegram] Fix command error:`, err);
|
|
1240
|
-
await telegram.sendToTopic(topicId, `Something went wrong while trying to fix that: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
|
|
1250
|
+
//
|
|
1251
|
+
// These ONLY apply in the Agent Attention topic. Scoping the gate there
|
|
1252
|
+
// (shouldInterceptFixCommand) is deliberate: previously the verb test ran
|
|
1253
|
+
// in every topic, so a message starting with restart/fix/clean in a normal
|
|
1254
|
+
// conversation was swallowed and bounced back with an "I didn't recognize
|
|
1255
|
+
// that command" help list — which is exactly why typing "restart sessions"
|
|
1256
|
+
// in a stuck session's own topic never reached the session. Outside the
|
|
1257
|
+
// attention topic the message now falls through to session routing below.
|
|
1258
|
+
if (fixCommandHandler && shouldInterceptFixCommand(text, topicId, getAttentionTopicId?.())) {
|
|
1259
|
+
(async () => {
|
|
1260
|
+
try {
|
|
1261
|
+
const handled = await fixCommandHandler(topicId, text);
|
|
1262
|
+
if (!handled) {
|
|
1263
|
+
// In the attention topic, but not a recognized fix command — show help.
|
|
1264
|
+
await telegram.sendToTopic(topicId, `I didn't recognize that command. Available fix commands:\n` +
|
|
1265
|
+
`• "fix auth" — Generate an API security token\n` +
|
|
1266
|
+
`• "fix lifeline" — Restart the crash-recovery system\n` +
|
|
1267
|
+
`• "fix shadow" — Remove shadow installation\n` +
|
|
1268
|
+
`• "clean processes" — Kill external Claude processes\n` +
|
|
1269
|
+
`• "restart" — Restart the server\n` +
|
|
1270
|
+
`• "restart sessions" — Restart stuck sessions`);
|
|
1241
1271
|
}
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1272
|
+
}
|
|
1273
|
+
catch (err) {
|
|
1274
|
+
console.error(`[telegram] Fix command error:`, err);
|
|
1275
|
+
await telegram.sendToTopic(topicId, `Something went wrong while trying to fix that: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
|
|
1276
|
+
}
|
|
1277
|
+
})();
|
|
1278
|
+
return;
|
|
1245
1279
|
}
|
|
1246
1280
|
// ── Pipeline-typed routing ──────────────────────────────────────
|
|
1247
1281
|
// Convert to PipelineMessage — types enforce that sender identity
|
|
@@ -3717,7 +3751,7 @@ export async function startServer(options) {
|
|
|
3717
3751
|
// reached the AI session as plain chat text.
|
|
3718
3752
|
const userManagerSendOnly = new UserManager(config.stateDir, config.users);
|
|
3719
3753
|
_fixDeps = { state, liveConfig, sessionManager, telegram, config };
|
|
3720
|
-
wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManagerSendOnly, (topicId, text) => handleFixCommand(topicId, text, _fixDeps), () => (collaborationSurfacer && conversationStore && telegram) ? { collaborationSurfacer, conversationStore, commitmentTracker, telegram, brief: briefDeps } : null, () => _agentServerRef?.getTopicOperatorStore() ?? null);
|
|
3754
|
+
wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManagerSendOnly, (topicId, text) => handleFixCommand(topicId, text, _fixDeps), () => (collaborationSurfacer && conversationStore && telegram) ? { collaborationSurfacer, conversationStore, commitmentTracker, telegram, brief: briefDeps } : null, () => _agentServerRef?.getTopicOperatorStore() ?? null, () => state.get('agent-attention-topic'));
|
|
3721
3755
|
wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, undefined, config.sessions.claudePath, topicMemory);
|
|
3722
3756
|
console.log(pc.green(' Telegram routing + command callbacks wired (send-only)'));
|
|
3723
3757
|
}
|
|
@@ -3843,7 +3877,7 @@ export async function startServer(options) {
|
|
|
3843
3877
|
config,
|
|
3844
3878
|
};
|
|
3845
3879
|
// Wire up topic → session routing and session management callbacks
|
|
3846
|
-
wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManager, (topicId, text) => handleFixCommand(topicId, text, _fixDeps), () => (collaborationSurfacer && conversationStore && telegram) ? { collaborationSurfacer, conversationStore, commitmentTracker, telegram, brief: briefDeps } : null, () => _agentServerRef?.getTopicOperatorStore() ?? null);
|
|
3880
|
+
wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManager, (topicId, text) => handleFixCommand(topicId, text, _fixDeps), () => (collaborationSurfacer && conversationStore && telegram) ? { collaborationSurfacer, conversationStore, commitmentTracker, telegram, brief: briefDeps } : null, () => _agentServerRef?.getTopicOperatorStore() ?? null, () => state.get('agent-attention-topic'));
|
|
3847
3881
|
wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, accountSwitcher, config.sessions.claudePath, topicMemory);
|
|
3848
3882
|
// Wire up unknown-user handling (Multi-User Setup Wizard Phase 4.5)
|
|
3849
3883
|
telegram.onGetRegistrationPolicy = () => ({
|
|
@@ -4339,6 +4373,20 @@ export async function startServer(options) {
|
|
|
4339
4373
|
const channelId = message.channel.identifier;
|
|
4340
4374
|
const isDM = message.metadata?.isDM;
|
|
4341
4375
|
const senderName = message.metadata?.senderName || 'User';
|
|
4376
|
+
// ── Thread → session routing (§5.3) ──────────────────────────────────
|
|
4377
|
+
// The Slack channelId is ALWAYS the address we talk to the Slack API with
|
|
4378
|
+
// (replies, reactions, history). The session REGISTRY + resume map are
|
|
4379
|
+
// keyed on a routing key that, when thread routing is opted in for this
|
|
4380
|
+
// channel and the message is a reply inside a thread, becomes
|
|
4381
|
+
// `<channelId>:<thread_ts>` — giving that thread its own isolated session,
|
|
4382
|
+
// mirroring Telegram topic→session. Default (no opt-in / no thread_ts):
|
|
4383
|
+
// routingKey === channelId, byte-for-byte today's behavior.
|
|
4384
|
+
const messageTs = message.metadata?.ts;
|
|
4385
|
+
const threadTs = message.metadata?.threadTs;
|
|
4386
|
+
const routingKey = slackAdapter.resolveRoutingKey(channelId, threadTs, messageTs);
|
|
4387
|
+
const isThreadSession = slackAdapter.isThreadRoutingKey(routingKey);
|
|
4388
|
+
// The thread_ts to thread replies under (only when this is a thread session).
|
|
4389
|
+
const replyThreadTs = isThreadSession ? threadTs : undefined;
|
|
4342
4390
|
// Sentinel intercept — classify message for emergency stop/pause
|
|
4343
4391
|
if (sentinel) {
|
|
4344
4392
|
try {
|
|
@@ -4356,7 +4404,7 @@ export async function startServer(options) {
|
|
|
4356
4404
|
return;
|
|
4357
4405
|
}
|
|
4358
4406
|
else if (classification.category === 'pause') {
|
|
4359
|
-
const existingSession = slackAdapter.getSessionForChannel(
|
|
4407
|
+
const existingSession = slackAdapter.getSessionForChannel(routingKey);
|
|
4360
4408
|
if (existingSession) {
|
|
4361
4409
|
sessionManager.sendKey(existingSession, 'Escape');
|
|
4362
4410
|
slackAdapter.sendToChannel(channelId, '⏸️ Session paused.').catch(() => { });
|
|
@@ -4413,9 +4461,16 @@ export async function startServer(options) {
|
|
|
4413
4461
|
contextLines.push('CRITICAL: You MUST relay your response back to Slack after responding.');
|
|
4414
4462
|
contextLines.push('Use the relay script (write ONLY your reply text — do NOT pipe or cat this file into the script):');
|
|
4415
4463
|
contextLines.push('');
|
|
4416
|
-
|
|
4464
|
+
// Thread session: pass the thread_ts as the 2nd arg so the reply lands IN
|
|
4465
|
+
// the thread (not the channel root). Channel session: channelId only.
|
|
4466
|
+
const replyTarget = replyThreadTs ? `${channelId} ${replyThreadTs}` : `${channelId}`;
|
|
4467
|
+
contextLines.push(`cat <<'EOF' | .claude/scripts/slack-reply.sh ${replyTarget}`);
|
|
4417
4468
|
contextLines.push('Your response text here');
|
|
4418
4469
|
contextLines.push('EOF');
|
|
4470
|
+
if (replyThreadTs) {
|
|
4471
|
+
contextLines.push('');
|
|
4472
|
+
contextLines.push('(This is a THREAD conversation — keep your reply in this thread by passing the thread id shown above as the 2nd argument.)');
|
|
4473
|
+
}
|
|
4419
4474
|
contextLines.push('');
|
|
4420
4475
|
contextLines.push('Strip the [slack:] prefix before interpreting the message.');
|
|
4421
4476
|
contextLines.push('Only relay conversational text — not tool output, file contents, or internal reasoning.');
|
|
@@ -4451,8 +4506,8 @@ export async function startServer(options) {
|
|
|
4451
4506
|
else {
|
|
4452
4507
|
bootstrapMessage = fullMessage;
|
|
4453
4508
|
}
|
|
4454
|
-
// Check for existing session bound to this channel
|
|
4455
|
-
const existingSession = slackAdapter.getSessionForChannel(
|
|
4509
|
+
// Check for existing session bound to this channel/thread (routing key)
|
|
4510
|
+
const existingSession = slackAdapter.getSessionForChannel(routingKey);
|
|
4456
4511
|
if (existingSession) {
|
|
4457
4512
|
// Try to inject into existing session via tmux
|
|
4458
4513
|
const sessions = sessionManager.listRunningSessions();
|
|
@@ -4490,20 +4545,26 @@ export async function startServer(options) {
|
|
|
4490
4545
|
}
|
|
4491
4546
|
console.log(`[slack→session] Session "${existingSession}" died, respawning...`);
|
|
4492
4547
|
}
|
|
4493
|
-
// Check resume map for session continuity
|
|
4494
|
-
|
|
4548
|
+
// Check resume map for session continuity (keyed on routing key, so a
|
|
4549
|
+
// thread resumes its OWN session and not the channel-root one).
|
|
4550
|
+
const resumeInfo = slackAdapter.getChannelResume(routingKey);
|
|
4495
4551
|
const resumeSessionId = resumeInfo?.uuid ?? undefined;
|
|
4496
4552
|
if (resumeInfo) {
|
|
4497
|
-
slackAdapter.removeChannelResume(
|
|
4553
|
+
slackAdapter.removeChannelResume(routingKey);
|
|
4498
4554
|
}
|
|
4499
|
-
// Route: DMs go to lifeline session, channels spawn new sessions
|
|
4500
|
-
|
|
4555
|
+
// Route: DMs go to lifeline session, channels/threads spawn new sessions.
|
|
4556
|
+
// A thread session NEVER folds into the DM lifeline — it is its own
|
|
4557
|
+
// isolated work session (DMs don't carry thread_ts anyway).
|
|
4558
|
+
const targetSession = (isDM && !isThreadSession) ? 'lifeline' : undefined;
|
|
4501
4559
|
try {
|
|
4502
|
-
const newSessionName = await sessionManager.spawnInteractiveSession(bootstrapMessage, targetSession, { resumeSessionId, slackChannelId: channelId });
|
|
4560
|
+
const newSessionName = await sessionManager.spawnInteractiveSession(bootstrapMessage, targetSession, { resumeSessionId, slackChannelId: channelId, slackThreadTs: replyThreadTs });
|
|
4503
4561
|
if (newSessionName) {
|
|
4504
|
-
|
|
4562
|
+
// Register on the routing key (channelId for a channel session,
|
|
4563
|
+
// `<channelId>:<thread_ts>` for a thread session). channelName carries
|
|
4564
|
+
// a thread hint so the registry stays human-readable.
|
|
4565
|
+
slackAdapter.registerChannelSession(routingKey, newSessionName, isThreadSession ? `${channelId} (thread ${replyThreadTs})` : undefined);
|
|
4505
4566
|
slackAdapter.trackMessageInjection(channelId, newSessionName, message.content);
|
|
4506
|
-
console.log(`[slack→session] ${resumeSessionId ? 'Resumed' : 'Spawned'} "${newSessionName}" for channel ${channelId}`);
|
|
4567
|
+
console.log(`[slack→session] ${resumeSessionId ? 'Resumed' : 'Spawned'} "${newSessionName}" for ${isThreadSession ? `thread ${routingKey}` : `channel ${channelId}`}`);
|
|
4507
4568
|
}
|
|
4508
4569
|
}
|
|
4509
4570
|
catch (err) {
|
|
@@ -5516,17 +5577,24 @@ export async function startServer(options) {
|
|
|
5516
5577
|
}
|
|
5517
5578
|
console.log(`[respawnSessionFresh] topicId=${topicId} slackChId=${slackChId || 'none'} slackAdapter=${!!_slackAdapter} session=${_sessionName} mapSize=${slackProxyChannelMap.size}`);
|
|
5518
5579
|
if (slackChId && _slackAdapter) {
|
|
5580
|
+
// Thread→session (§5.3): slackChId may be a routing key
|
|
5581
|
+
// (`<channelId>:<thread_ts>`). The registry + resume map are keyed on
|
|
5582
|
+
// the routing key (slackChId); the Slack API + reply instruction need
|
|
5583
|
+
// the RAW channel id, and a thread reply needs the embedded thread_ts.
|
|
5584
|
+
const parsedTarget = _slackAdapter.parseRoutingKey(slackChId);
|
|
5585
|
+
const slackApiChannel = parsedTarget.channelId;
|
|
5586
|
+
const slackReplyThread = parsedTarget.threadTs;
|
|
5519
5587
|
// Kill existing session (already flagged in contextExhaustionKills via event listener)
|
|
5520
5588
|
const session = sessionManager.listRunningSessions().find(s => s.tmuxSession === _sessionName);
|
|
5521
5589
|
if (session)
|
|
5522
5590
|
sessionManager.killSession(session.id);
|
|
5523
|
-
// Clear the channel resume so the new session starts fresh
|
|
5591
|
+
// Clear the channel/thread resume so the new session starts fresh
|
|
5524
5592
|
_slackAdapter.removeChannelResume(slackChId);
|
|
5525
5593
|
// Spawn a fresh session with recovery context
|
|
5526
5594
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
5527
5595
|
// Build a recovery bootstrap message with thread history (inline, matching Telegram pattern)
|
|
5528
5596
|
// Use async fallback to fetch from Slack API if ring buffer is empty (race condition on restart)
|
|
5529
|
-
const history = await _slackAdapter.getChannelMessagesWithFallback(
|
|
5597
|
+
const history = await _slackAdapter.getChannelMessagesWithFallback(slackApiChannel, 30);
|
|
5530
5598
|
const botUserId = _slackAdapter.getBotUserId?.() ?? null;
|
|
5531
5599
|
const lines = [];
|
|
5532
5600
|
lines.push(`CONTINUATION — You are resuming an EXISTING Slack conversation after context exhaustion. Read the context below and pick up where you left off. Do NOT ask what was being discussed.`);
|
|
@@ -5547,25 +5615,28 @@ export async function startServer(options) {
|
|
|
5547
5615
|
lines.push('--- End Thread History ---');
|
|
5548
5616
|
}
|
|
5549
5617
|
else {
|
|
5550
|
-
console.warn(`[slack→recovery] WARNING: No history available for channel ${
|
|
5618
|
+
console.warn(`[slack→recovery] WARNING: No history available for channel ${slackApiChannel} — recovery context is empty. Ring buffer may not be populated yet.`);
|
|
5551
5619
|
lines.push('[WARNING: Thread history unavailable — ring buffer may not be populated. Check Slack channel for recent messages before responding.]');
|
|
5552
5620
|
}
|
|
5553
5621
|
lines.push('');
|
|
5554
5622
|
lines.push('CRITICAL: You MUST relay your response back to Slack.');
|
|
5555
|
-
|
|
5623
|
+
// Thread session: include the thread_ts so the recovered reply threads.
|
|
5624
|
+
const recoveryReplyTarget = slackReplyThread ? `${slackApiChannel} ${slackReplyThread}` : `${slackApiChannel}`;
|
|
5625
|
+
lines.push(`cat <<'EOF' | .claude/scripts/slack-reply.sh ${recoveryReplyTarget}`);
|
|
5556
5626
|
lines.push('Your response text here');
|
|
5557
5627
|
lines.push('EOF');
|
|
5558
5628
|
const tmpDir = '/tmp/instar-slack';
|
|
5559
5629
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
5560
|
-
const ctxPath = path.join(tmpDir, `recovery-${
|
|
5630
|
+
const ctxPath = path.join(tmpDir, `recovery-${slackApiChannel}-${Date.now()}.txt`);
|
|
5561
5631
|
const contextData = lines.join('\n');
|
|
5562
5632
|
fs.writeFileSync(ctxPath, contextData);
|
|
5563
|
-
const bootstrapMessage = `[slack:${
|
|
5633
|
+
const bootstrapMessage = `[slack:${slackApiChannel}] ${contextData}`;
|
|
5564
5634
|
try {
|
|
5565
|
-
|
|
5635
|
+
// Spawn with the RAW channel (+ thread_ts) but register on the routing key.
|
|
5636
|
+
const newSessionName = await sessionManager.spawnInteractiveSession(bootstrapMessage, undefined, { slackChannelId: slackApiChannel, slackThreadTs: slackReplyThread });
|
|
5566
5637
|
if (newSessionName) {
|
|
5567
5638
|
_slackAdapter.registerChannelSession(slackChId, newSessionName);
|
|
5568
|
-
console.log(`[slack→recovery] Fresh session "${newSessionName}" spawned for
|
|
5639
|
+
console.log(`[slack→recovery] Fresh session "${newSessionName}" spawned for ${slackReplyThread ? `thread ${slackChId}` : `channel ${slackApiChannel}`} (context exhaustion recovery)`);
|
|
5569
5640
|
}
|
|
5570
5641
|
}
|
|
5571
5642
|
catch (err) {
|