@gajae-code/coding-agent 0.12.11 → 0.12.12

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 (112) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/session-manager.d.ts +23 -5
  42. package/dist/types/tools/index.d.ts +8 -0
  43. package/dist/types/utils/npm-registry.d.ts +71 -0
  44. package/examples/extensions/README.md +38 -4
  45. package/examples/extensions/ooo-bridge.ts +15 -0
  46. package/package.json +8 -8
  47. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  48. package/src/async/job-manager.ts +9 -2
  49. package/src/cli/notify-cli.ts +226 -2
  50. package/src/cli/update-cli.ts +90 -23
  51. package/src/commands/notify.ts +27 -3
  52. package/src/commands/sdk.ts +1 -0
  53. package/src/config/file-lock-gc.ts +18 -0
  54. package/src/config/file-lock.ts +61 -16
  55. package/src/config/model-profiles.ts +31 -15
  56. package/src/coordinator/contract.ts +1 -0
  57. package/src/coordinator-mcp/question-state.ts +2 -0
  58. package/src/coordinator-mcp/server.ts +358 -7
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  60. package/src/extensibility/extensions/index.ts +1 -0
  61. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  62. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  63. package/src/extensibility/extensions/runner.ts +15 -2
  64. package/src/extensibility/extensions/types.ts +2 -0
  65. package/src/gjc-runtime/launch-tmux.ts +53 -1
  66. package/src/internal-urls/docs-index.generated.ts +11 -10
  67. package/src/main.ts +203 -67
  68. package/src/modes/DESIGN.md +8 -5
  69. package/src/modes/components/footer.ts +10 -18
  70. package/src/modes/components/session-selector.ts +26 -3
  71. package/src/modes/components/status-line/gh.ts +23 -2
  72. package/src/modes/controllers/event-controller.ts +3 -0
  73. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  74. package/src/modes/controllers/input-controller.ts +11 -0
  75. package/src/modes/controllers/selector-controller.ts +8 -2
  76. package/src/modes/interactive-mode.ts +7 -1
  77. package/src/modes/utils/host-status.ts +38 -0
  78. package/src/runtime-mcp/types.ts +2 -0
  79. package/src/sdk/broker/lifecycle.ts +60 -3
  80. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  81. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  82. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  83. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  84. package/src/sdk/bus/control-plane-frames.ts +51 -0
  85. package/src/sdk/bus/conversation-store.ts +29 -1
  86. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  87. package/src/sdk/bus/index.ts +70 -0
  88. package/src/sdk/bus/notification-service.ts +28 -1
  89. package/src/sdk/bus/rich-render.ts +3 -2
  90. package/src/sdk/bus/slack-daemon.ts +99 -4
  91. package/src/sdk/bus/slack-live-provider.ts +32 -2
  92. package/src/sdk/bus/slack-provider.ts +11 -0
  93. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  94. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  95. package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
  96. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  97. package/src/sdk/bus/telegram-reference.ts +42 -6
  98. package/src/sdk/bus/topic-registry.ts +674 -181
  99. package/src/sdk/host/host.ts +180 -1
  100. package/src/sdk/lifecycle-session.ts +8 -2
  101. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  102. package/src/sdk/session-activation.ts +127 -0
  103. package/src/sdk/session.ts +139 -28
  104. package/src/sdk/startup-capability.ts +10 -1
  105. package/src/session/agent-session.ts +220 -9
  106. package/src/session/artifacts.ts +56 -13
  107. package/src/session/internal/managed-session-storage.ts +206 -7
  108. package/src/session/session-manager.ts +148 -38
  109. package/src/setup/provider-presets.json +1 -1
  110. package/src/task/index.ts +92 -44
  111. package/src/tools/index.ts +8 -0
  112. package/src/utils/npm-registry.ts +660 -0
@@ -79,6 +79,7 @@ import {
79
79
  getCurrentTelegramActivationMarker,
80
80
  getNotificationConfig,
81
81
  isProviderEffectivelyEnabled,
82
+ isSlackComplete,
82
83
  type NotificationConfig,
83
84
  type NotificationSettingsReader,
84
85
  resolveGenericNotificationSessionEligibility,
@@ -90,6 +91,12 @@ import {
90
91
  runIdentityControlSuccessPath,
91
92
  type TerminalSendOutcome,
92
93
  } from "./control-drain-lease";
94
+ import { ConversationStore } from "./conversation-store";
95
+ import {
96
+ createSlackBindingActivationGate,
97
+ EXISTING_THREAD_BIND_ENV,
98
+ isExistingThreadBindingRequested,
99
+ } from "./existing-thread-readiness";
93
100
  import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage, truncate } from "./helpers";
94
101
  import { createKindAwareReconciliation } from "./kind-aware-reconciliation";
95
102
  import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
@@ -97,6 +104,7 @@ import { proposedTelegramIdentity } from "./notification-orchestration";
97
104
  import { createPromptReconciliation, sanitizePromptFailure } from "./prompt-reconciliation";
98
105
  import { createReconciliationStore } from "./reconciliation-store";
99
106
  import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
107
+ import type { SlackConversation } from "./slack-conversation";
100
108
  import {
101
109
  ASK_SELECTED_ACK_CAPABILITY,
102
110
  type EnsureDaemonResult,
@@ -3514,6 +3522,8 @@ export function createNotificationsExtension(
3514
3522
  const sdkEnabledForSession =
3515
3523
  (options.sdkHostModeSupported ?? true) && shouldHostSdk(settings, isNotificationEligibleContext(ctx));
3516
3524
  const lifecycleRequired = lifecycleStartupCapability !== undefined;
3525
+ /** The broker-issued readiness intent for this exact lifecycle-managed session. */
3526
+ const lifecycleReadiness = lifecycleStartupCapability?.readiness ?? "immediate";
3517
3527
  const failLifecycleStartup = (
3518
3528
  reason: "disabled" | "ineligible" | "failed",
3519
3529
  error?: unknown,
@@ -4277,10 +4287,70 @@ export function createNotificationsExtension(
4277
4287
  }
4278
4288
  };
4279
4289
 
4290
+ /**
4291
+ * Existing-thread preparation.
4292
+ *
4293
+ * A prepared session withholds its readiness signal so
4294
+ * `gjc notify bind-thread` can adopt an operator-supplied Slack root before
4295
+ * any stock root is published; activation then publishes readiness once and
4296
+ * the daemon adopts that root.
4297
+ *
4298
+ * Preparation has exactly two authorities, and they never overlap. A
4299
+ * broker lifecycle-managed session is prepared only by the broker-issued,
4300
+ * session-scoped readiness intent on its launch request, which the
4301
+ * lifecycle wait completes on the prepared signal instead of readiness. A
4302
+ * manual/source session keeps the explicit `GJC_NOTIFY_BIND_EXISTING_THREAD`
4303
+ * opt-in, which is refused for lifecycle-managed sessions so an inherited
4304
+ * process-global flag can never silently defer a broker-created session.
4305
+ *
4306
+ * The activation gate is the existing-thread bind authority itself: it
4307
+ * proves a daemon-owned mapping exists at this exact endpoint generation,
4308
+ * so activation before a binding fails closed with no grace period. It can
4309
+ * only be built from a configured, enabled Slack target plus the agent
4310
+ * directory holding that mapping, so a preparation request that cannot
4311
+ * produce one has no bind authority at all and is refused here. Degrading
4312
+ * such a request to ordinary immediate readiness would answer "prepare" with
4313
+ * a session that publishes its own root anyway, and preparing without the
4314
+ * gate would hand back a prepared session that activates with no binding.
4315
+ */
4316
+ const preparationAgentDir = settings?.getAgentDir?.();
4317
+ const slackBindTarget =
4318
+ notificationsEnabledForSession && isSlackComplete(cfg)
4319
+ ? { teamId: cfg.slack.workspaceId, channelId: cfg.slack.channelId }
4320
+ : undefined;
4321
+ const envRequestsPreparation = isExistingThreadBindingRequested(process.env);
4322
+ if (envRequestsPreparation && lifecycleRequired)
4323
+ return failLifecycleStartup(
4324
+ "failed",
4325
+ `${EXISTING_THREAD_BIND_ENV}=1 is not supported for broker lifecycle-managed sessions; use the broker readiness intent.`,
4326
+ );
4327
+ const preparesExistingThread = lifecycleRequired ? lifecycleReadiness === "deferred" : envRequestsPreparation;
4328
+ const activationGate =
4329
+ preparesExistingThread && preparationAgentDir && slackBindTarget
4330
+ ? createSlackBindingActivationGate({
4331
+ store: new ConversationStore<SlackConversation>({ agentDir: preparationAgentDir, kind: "slack" }),
4332
+ teamId: slackBindTarget.teamId,
4333
+ channelId: slackBindTarget.channelId,
4334
+ })
4335
+ : undefined;
4336
+ if (preparesExistingThread && !activationGate) {
4337
+ const missing = slackBindTarget
4338
+ ? "an agent directory"
4339
+ : "a configured Slack notification target for this session";
4340
+ if (lifecycleRequired)
4341
+ return failLifecycleStartup(
4342
+ "failed",
4343
+ `Existing-thread preparation requires ${missing} to prove the existing-thread binding.`,
4344
+ );
4345
+ throw new Error(`${EXISTING_THREAD_BIND_ENV}=1 requires ${missing} to prove the existing-thread binding.`);
4346
+ }
4347
+
4280
4348
  host = new SessionSdkHost({
4281
4349
  sessionId: id,
4282
4350
  stateRoot,
4283
4351
  token,
4352
+ ...(preparesExistingThread ? { readiness: "deferred" as const } : {}),
4353
+ ...(activationGate ? { activationGate } : {}),
4284
4354
  sendFrame: (connectionId, frame) => sendSdkFrame(connectionId, frame),
4285
4355
  connectionCapabilities: connectionId => hostCapCache.get(connectionId),
4286
4356
  installProviderDefinitions,
@@ -182,12 +182,39 @@ export async function readNotificationEndpointFile(file: string): Promise<Notifi
182
182
  /** Bump when the native exact-deletion identity contract changes. */
183
183
  export const NATIVE_PATH_IDENTITY_CONTRACT_VERSION = 1;
184
184
 
185
+ /**
186
+ * Remove exactly the inspected regular file.
187
+ *
188
+ * Intermediate directory symlinks (e.g. multi-account layouts that share
189
+ * `notifications/` via a directory symlink) are resolved before the native
190
+ * no-follow unlink. Only the parent path is canonicalized; the final basename
191
+ * is rejoined so native exact_unlink still applies AT_SYMLINK_NOFOLLOW to the
192
+ * final path component at mutation time. A final-component file symlink stays
193
+ * `reparse_point` (including TOCTOU replacement after JS preflight).
194
+ */
185
195
  export function exactUnlinkNotificationFile(
186
196
  file: string,
187
197
  identity: NotificationEndpointFileIdentity,
188
198
  quarantineName: string,
189
199
  ): NotificationExactUnlinkResult {
190
- const result = native.exactUnlink(file, { ...identity, quarantineName });
200
+ let target = file;
201
+ try {
202
+ const st = fsSync.lstatSync(file);
203
+ if (st.isSymbolicLink()) {
204
+ return { ok: false, code: "reparse_point" };
205
+ }
206
+ // Native exact_unlink walks every path component with O_NOFOLLOW and
207
+ // rejects intermediate directory reparse points. Resolve only the
208
+ // directory prefix so a shared notifications dir still unlinks by
209
+ // inode, while the final basename remains subject to no-follow.
210
+ const parent = path.dirname(file);
211
+ const base = path.basename(file);
212
+ const resolvedParent = fsSync.realpathSync(parent);
213
+ target = path.join(resolvedParent, base);
214
+ } catch {
215
+ // Missing/unreadable paths fall through; the native call reports the failure.
216
+ }
217
+ const result = native.exactUnlink(target, { ...identity, quarantineName });
191
218
  return {
192
219
  ok: result.ok,
193
220
  code: result.code,
@@ -124,8 +124,9 @@ export async function deliverRichActionWithFallback(
124
124
  const fallbackId = await htmlFallback();
125
125
  return { messageId: fallbackId, usedRich: false, usedFallback: true };
126
126
  }
127
- const candidate = (res as { result?: { message_id?: unknown } } | null)?.result?.message_id;
128
- if (validMessageId(candidate)) return { messageId: candidate, usedRich: true, usedFallback: false };
127
+ const candidate = (res as { ok?: unknown; result?: { message_id?: unknown } } | null)?.result?.message_id;
128
+ if ((res as { ok?: unknown } | null)?.ok === true && validMessageId(candidate))
129
+ return { messageId: candidate, usedRich: true, usedFallback: false };
129
130
  log?.warn("notifications: sendRichMessage(action) outcome ambiguous; not falling back to HTML");
130
131
  } catch (err) {
131
132
  const failure = err instanceof Error ? err.message : String(err);
@@ -11,6 +11,11 @@ import {
11
11
  slackConversationKey,
12
12
  } from "./slack-conversation";
13
13
 
14
+ import { assertBoundedSlackRootTs, claimSlackThreadBinding, SlackThreadBindingError } from "./slack-thread-binding";
15
+
16
+ export type { SlackThreadBindingErrorCode } from "./slack-thread-binding";
17
+ export { SlackThreadBindingError } from "./slack-thread-binding";
18
+
14
19
  export class SlackEndpointBindingError extends Error {
15
20
  constructor(message = "Slack session endpoint changed before dispatch.") {
16
21
  super(message);
@@ -32,7 +37,12 @@ class SlackReconciledAbsentEffectError extends Error {
32
37
 
33
38
  import { type ChatEffect, ChatEffectJournal, type ChatEffectLease } from "./chat-effect-journal";
34
39
  import { SlackProviderError } from "./slack-live-provider";
35
- import type { SlackPostedMessage, SlackProvider, SlackSocketEnvelope } from "./slack-provider";
40
+ import type {
41
+ SlackMessageSearchResult,
42
+ SlackPostedMessage,
43
+ SlackProvider,
44
+ SlackSocketEnvelope,
45
+ } from "./slack-provider";
36
46
 
37
47
  // Durable filesystem publication leases must outlast one event-loop and persistence turn.
38
48
  const MIN_PUBLICATION_LEASE_MS = 100;
@@ -45,6 +55,12 @@ export interface SlackSdkClient {
45
55
  send(frame: Record<string, unknown>): void;
46
56
  }
47
57
 
58
+ /** Proven right to bind one session at one endpoint generation. */
59
+ export interface SlackBindingAuthority {
60
+ sessionId: string;
61
+ endpointGeneration: number;
62
+ }
63
+
48
64
  export interface SlackNotificationDaemonOptions {
49
65
  agentDir: string;
50
66
  repo: string;
@@ -62,6 +78,12 @@ export interface SlackNotificationDaemonOptions {
62
78
  publicationLeaseMs?: number;
63
79
 
64
80
  resolveEndpoint?: (sessionId: string) => Promise<SlackEndpoint | null>;
81
+ /**
82
+ * Exact discovery/attachment authority for adopting an existing root. The
83
+ * runtime supplies index, endpoint, and attachment proof; without it only the
84
+ * resolvable endpoint generation can be proven.
85
+ */
86
+ resolveBindingAuthority?: (sessionId: string) => Promise<SlackBindingAuthority | undefined>;
65
87
  createClient: (endpoint: SlackEndpoint) => SlackSdkClient;
66
88
  onCommand?: (
67
89
  sessionId: string,
@@ -348,6 +370,76 @@ export class SlackNotificationDaemon {
348
370
  return (await this.#postRoot(sessionId, body, endpointGeneration)).conversation;
349
371
  }
350
372
 
373
+ /**
374
+ * Adopt an operator-supplied root in the configured workspace and channel
375
+ * without publishing a replacement root.
376
+ *
377
+ * The root is verified against the provider before any lock is taken, and the
378
+ * claim re-proves session authority inside the store lock. When the caller is
379
+ * the daemon command channel it also supplies `commitAuthority`, which
380
+ * re-proves the exact daemon owner tuple and takes terminal request
381
+ * authority in the same fence, so an ownership change or a cancellation
382
+ * between dispatch and commit leaves the store untouched. The claim targets
383
+ * the same session key stock publication uses, so a concurrent first
384
+ * notification observes the adopted root instead of posting a second one.
385
+ */
386
+ async bindExistingRoot(
387
+ sessionId: string,
388
+ rootTs: string,
389
+ commitAuthority?: () => Promise<boolean>,
390
+ ): Promise<SlackConversation> {
391
+ assertBoundedSlackRootTs(rootTs);
392
+ const authority = await this.#bindingAuthority(sessionId);
393
+ if (!authority)
394
+ throw new SlackThreadBindingError(
395
+ "session_not_live",
396
+ "Slack thread binding requires an exact live session endpoint.",
397
+ );
398
+ await this.#verifyExistingRoot(rootTs);
399
+ return await claimSlackThreadBinding({
400
+ store: this.store,
401
+ key: this.#intentKey(sessionId),
402
+ teamId: this.options.teamId,
403
+ channelId: this.options.channelId,
404
+ sessionId,
405
+ rootTs,
406
+ endpointGeneration: authority.endpointGeneration,
407
+ revalidate: async () => {
408
+ if ((await this.#bindingAuthority(sessionId))?.endpointGeneration !== authority.endpointGeneration)
409
+ return false;
410
+ return commitAuthority ? await commitAuthority() : true;
411
+ },
412
+ now: this.#now,
413
+ });
414
+ }
415
+
416
+ /**
417
+ * Endpoint authority for a binding. The runtime injects exact discovery and
418
+ * attachment authority; the fallback accepts only a resolvable endpoint with a
419
+ * usable generation, which is all a directly constructed daemon can prove.
420
+ */
421
+ async #bindingAuthority(sessionId: string): Promise<SlackBindingAuthority | undefined> {
422
+ if (this.options.resolveBindingAuthority) return await this.options.resolveBindingAuthority(sessionId);
423
+ const endpoint = await this.#resolveEndpoint(sessionId);
424
+ if (!endpoint || !Number.isSafeInteger(endpoint.generation) || endpoint.generation <= 0) return undefined;
425
+ return { sessionId, endpointGeneration: endpoint.generation };
426
+ }
427
+
428
+ /** Prove the operator-supplied root exists in the configured channel before persisting anything. */
429
+ async #verifyExistingRoot(rootTs: string): Promise<void> {
430
+ let found: SlackMessageSearchResult | null;
431
+ try {
432
+ found = await this.options.provider.findMessageByTimestamp({ channel: this.options.channelId, ts: rootTs });
433
+ } catch {
434
+ throw new SlackThreadBindingError(
435
+ "provider_unavailable",
436
+ "Slack could not be reached to verify the existing thread root.",
437
+ );
438
+ }
439
+ if (!found || found.ts !== rootTs || found.channel !== this.options.channelId)
440
+ throw new SlackThreadBindingError("root_not_found", "The Slack root was not found in the configured channel.");
441
+ }
442
+
351
443
  async #postRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackRootPublication> {
352
444
  const endpoint = await this.#resolveEndpoint(sessionId);
353
445
  if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
@@ -394,11 +486,14 @@ export class SlackNotificationDaemon {
394
486
  inboundDispatches: current?.inboundDispatches ?? [],
395
487
  };
396
488
  });
397
- if (!pending?.clientMsgId) throw new Error("Unable to persist Slack root post intent");
398
- if (pending.state === "active") {
489
+ // An already-active session root is authoritative whether this daemon
490
+ // published it or adopted an operator-supplied one, so it is checked before
491
+ // the publication identity a claim of our own would have written.
492
+ if (pending?.state === "active" && pending.rootTs) {
399
493
  if (pending.endpointGeneration !== generation) throw new SlackEndpointBindingError();
400
494
  return { conversation: pending, created: false };
401
495
  }
496
+ if (!pending?.clientMsgId) throw new Error("Unable to persist Slack root post intent");
402
497
  if (!claimed)
403
498
  return { conversation: await this.#waitForRoot(pendingKey, sessionId, body, generation), created: false };
404
499
  if (pending.endpointGeneration !== generation) throw new SlackEndpointBindingError();
@@ -498,7 +593,7 @@ export class SlackNotificationDaemon {
498
593
  bodyWasUsedAsRoot = publication.created;
499
594
  }
500
595
  if (!conversation.rootTs) return conversation;
501
- const key = this.#intentKey(sessionId);
596
+ const key = usedExistingRoot && existing ? existing.key : this.#intentKey(sessionId);
502
597
  const conversationGeneration = this.#requireEndpointGeneration(conversation);
503
598
  if (conversationGeneration !== generation) throw new SlackEndpointBindingError();
504
599
  if (bodyWasUsedAsRoot) {
@@ -97,6 +97,17 @@ function socketFromGlobal(url: string): SlackWebSocket {
97
97
  return new WebSocket(url);
98
98
  }
99
99
 
100
+ /**
101
+ * Slack's Web API rejects JSON bodies for read methods such as conversations.replies
102
+ * with `invalid_arguments`, so every call is encoded as a form body with undefined
103
+ * parameters omitted rather than serialized.
104
+ */
105
+ function formBody(body: Record<string, string | undefined>): string {
106
+ const parameters = new URLSearchParams();
107
+ for (const [key, value] of Object.entries(body)) if (value !== undefined) parameters.set(key, value);
108
+ return parameters.toString();
109
+ }
110
+
100
111
  /**
101
112
  * Production Socket Mode and Web API client. It keeps all connection state in
102
113
  * memory and deliberately never maintains a Slack cursor.
@@ -270,6 +281,22 @@ export class SlackLiveProvider implements SlackProviderClient, SlackDiagnosticPr
270
281
  return this.#findMessage(replies, input.clientMsgId, input.channel);
271
282
  }
272
283
 
284
+ /**
285
+ * Confirm that `ts` addresses a real message in `channel`. Slack answers
286
+ * `conversations.replies` with the addressed message first; a timestamp that
287
+ * is unknown, deleted, or lives in another channel produces a Web API error
288
+ * instead, which surfaces as a `SlackProviderError` the caller treats as an
289
+ * unverified root.
290
+ */
291
+ async findMessageByTimestamp(input: { channel: string; ts: string }): Promise<SlackMessageSearchResult | null> {
292
+ const response = await this.#api("conversations.replies", { channel: input.channel, ts: input.ts, limit: "1" });
293
+ for (const candidate of messages(response.messages)) {
294
+ if (string(candidate.ts) !== input.ts) continue;
295
+ return { channel: string(candidate.channel) ?? input.channel, ts: input.ts, client_msg_id: undefined };
296
+ }
297
+ return null;
298
+ }
299
+
273
300
  async #connect(generation = this.#lifecycleGeneration): Promise<void> {
274
301
  const opened = await this.#openSocketUrl();
275
302
  if (this.#stopped || generation !== this.#lifecycleGeneration) return;
@@ -385,8 +412,11 @@ export class SlackLiveProvider implements SlackProviderClient, SlackDiagnosticPr
385
412
  try {
386
413
  response = await this.#fetch(`${SLACK_API}/${operation}`, {
387
414
  method: "POST",
388
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" },
389
- body: JSON.stringify(body),
415
+ headers: {
416
+ Authorization: `Bearer ${token}`,
417
+ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
418
+ },
419
+ body: formBody(body),
390
420
  signal,
391
421
  });
392
422
  } catch {
@@ -55,6 +55,12 @@ export interface SlackProviderClient {
55
55
  clientMsgId: string;
56
56
  threadTs?: string;
57
57
  }): Promise<SlackMessageSearchResult | null>;
58
+ /**
59
+ * Prove that an operator-supplied timestamp addresses a real message in the
60
+ * requested channel. Adoption of an existing root is refused when a client
61
+ * cannot answer this, so verification is never silently skipped.
62
+ */
63
+ findMessageByTimestamp?(input: { channel: string; ts: string }): Promise<SlackMessageSearchResult | null>;
58
64
  readonly transportHealthy?: boolean;
59
65
  }
60
66
 
@@ -97,4 +103,9 @@ export class SlackProvider {
97
103
  }): Promise<SlackMessageSearchResult | null> {
98
104
  return (await this.client.findMessageByClientMsgId?.(input)) ?? null;
99
105
  }
106
+
107
+ /** Fail-closed root verification: a client without this capability can never confirm a root. */
108
+ async findMessageByTimestamp(input: { channel: string; ts: string }): Promise<SlackMessageSearchResult | null> {
109
+ return (await this.client.findMessageByTimestamp?.(input)) ?? null;
110
+ }
100
111
  }