@gajae-code/coding-agent 0.12.11 → 0.12.13
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/CHANGELOG.md +68 -0
- package/dist/types/cli/marketplace-hint.d.ts +28 -0
- package/dist/types/cli/notify-cli.d.ts +42 -1
- package/dist/types/cli/update-cli.d.ts +9 -3
- package/dist/types/cli.d.ts +10 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/config/file-lock-gc.d.ts +13 -1
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/models-config-schema.d.ts +99 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
- package/dist/types/main.d.ts +6 -2
- package/dist/types/modes/acp/acp-agent.d.ts +24 -1
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +23 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
- package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
- package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
- package/dist/types/sdk/bus/html-format.d.ts +10 -0
- package/dist/types/sdk/bus/notification-service.d.ts +10 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +28 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -17
- package/dist/types/sdk/bus/telegram-reference.d.ts +20 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
- package/dist/types/sdk/host/host.d.ts +52 -0
- package/dist/types/sdk/lifecycle-session.d.ts +6 -0
- package/dist/types/sdk/session-activation.d.ts +41 -0
- package/dist/types/sdk/session.d.ts +18 -0
- package/dist/types/sdk/startup-capability.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +7 -2
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +40 -6
- package/dist/types/tools/index.d.ts +15 -0
- package/dist/types/utils/npm-registry.d.ts +71 -0
- package/examples/extensions/README.md +38 -4
- package/examples/extensions/ooo-bridge.ts +15 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/async/job-manager.ts +9 -2
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/marketplace-hint.ts +39 -0
- package/src/cli/notify-cli.ts +226 -2
- package/src/cli/plugin-cli.ts +10 -0
- package/src/cli/update-cli.ts +90 -23
- package/src/cli.ts +23 -0
- package/src/commands/notify.ts +27 -3
- package/src/commands/sdk.ts +1 -0
- package/src/config/file-lock-gc.ts +65 -26
- package/src/config/file-lock.ts +61 -16
- package/src/config/model-profiles.ts +46 -30
- package/src/config/model-registry.ts +17 -5
- package/src/config/models-config-schema.ts +9 -4
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +2 -0
- package/src/coordinator-mcp/server.ts +358 -7
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
- package/src/extensibility/extensions/runner.ts +15 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
- package/src/gjc-runtime/deep-interview-state.ts +27 -3
- package/src/gjc-runtime/gc-render.ts +8 -1
- package/src/gjc-runtime/gc-runtime.ts +15 -1
- package/src/gjc-runtime/launch-tmux.ts +53 -1
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/main.ts +214 -70
- package/src/modes/DESIGN.md +8 -5
- package/src/modes/acp/acp-agent.ts +123 -7
- package/src/modes/components/footer.ts +10 -18
- package/src/modes/components/model-selector.ts +52 -5
- package/src/modes/components/session-selector.ts +26 -3
- package/src/modes/components/status-line/gh.ts +23 -2
- package/src/modes/controllers/event-controller.ts +3 -0
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +11 -0
- package/src/modes/controllers/selector-controller.ts +8 -2
- package/src/modes/interactive-mode.ts +7 -1
- package/src/modes/print-mode.ts +10 -0
- package/src/modes/utils/host-status.ts +38 -0
- package/src/prompts/system/project-prompt.md +19 -0
- package/src/runtime-mcp/types.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +60 -3
- package/src/sdk/bus/chat-daemon-cli.ts +42 -0
- package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
- package/src/sdk/bus/chat-daemon-control.ts +16 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
- package/src/sdk/bus/control-plane-frames.ts +51 -0
- package/src/sdk/bus/conversation-store.ts +29 -1
- package/src/sdk/bus/existing-thread-readiness.ts +155 -0
- package/src/sdk/bus/html-format.ts +19 -3
- package/src/sdk/bus/index.ts +91 -12
- package/src/sdk/bus/notification-service.ts +28 -1
- package/src/sdk/bus/rich-render.ts +3 -2
- package/src/sdk/bus/slack-daemon.ts +99 -4
- package/src/sdk/bus/slack-live-provider.ts +32 -2
- package/src/sdk/bus/slack-provider.ts +11 -0
- package/src/sdk/bus/slack-thread-binding.ts +641 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +28 -7
- package/src/sdk/bus/telegram-daemon.ts +2007 -509
- package/src/sdk/bus/telegram-reference.ts +114 -15
- package/src/sdk/bus/topic-registry.ts +674 -181
- package/src/sdk/host/host.ts +180 -1
- package/src/sdk/lifecycle-session.ts +8 -2
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session-activation.ts +127 -0
- package/src/sdk/session.ts +144 -29
- package/src/sdk/startup-capability.ts +10 -1
- package/src/session/agent-session.ts +220 -13
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +228 -7
- package/src/session/session-manager.ts +225 -60
- package/src/setup/provider-presets.json +3 -3
- package/src/task/index.ts +92 -44
- package/src/thinking-metadata.ts +2 -1
- package/src/tools/ask.ts +10 -0
- package/src/tools/index.ts +15 -0
- package/src/tools/todo-write.ts +4 -4
- package/src/utils/npm-registry.ts +660 -0
package/src/sdk/bus/index.ts
CHANGED
|
@@ -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,
|
|
@@ -829,16 +837,22 @@ export class PresentationArbiter {
|
|
|
829
837
|
...(presentation.recommendedIndex === undefined
|
|
830
838
|
? {}
|
|
831
839
|
: { recommendedIndex: presentation.recommendedIndex }),
|
|
832
|
-
controls
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
840
|
+
// A presentation that carries its own controls (the ask tool's
|
|
841
|
+
// Next/Done) keeps them; a durable workflow gate presents none,
|
|
842
|
+
// so its navigation control is synthesized here.
|
|
843
|
+
controls:
|
|
844
|
+
presentation.controls.length > 0
|
|
845
|
+
? presentation.controls
|
|
846
|
+
: presentation.multi
|
|
847
|
+
? [
|
|
848
|
+
{
|
|
849
|
+
id: "navigation_forward",
|
|
850
|
+
kind: "navigation",
|
|
851
|
+
label: presentation.navigationLabel ?? "Done",
|
|
852
|
+
enabled: presentation.allowEmpty || presentation.selectedOptions.length > 0,
|
|
853
|
+
},
|
|
854
|
+
]
|
|
855
|
+
: [],
|
|
842
856
|
},
|
|
843
857
|
{ redact: this.redact(), sessionTag: this.tag },
|
|
844
858
|
),
|
|
@@ -1816,9 +1830,12 @@ function registerInteractiveAnswerSource(
|
|
|
1816
1830
|
options: request.options,
|
|
1817
1831
|
controls: request.controls,
|
|
1818
1832
|
recommendedIndex: request.recommendedIndex,
|
|
1819
|
-
multi
|
|
1833
|
+
// The ask tool owns the multi-select loop and re-issues one request per
|
|
1834
|
+
// toggle; carrying its selection here is what makes the toggle visible
|
|
1835
|
+
// on a remote transport instead of an identical repeated prompt.
|
|
1836
|
+
multi: request.multi === true,
|
|
1820
1837
|
allowEmpty: false,
|
|
1821
|
-
selectedOptions: [],
|
|
1838
|
+
selectedOptions: [...(request.selectedOptions ?? [])],
|
|
1822
1839
|
onActivated: (actionId, lease) => {
|
|
1823
1840
|
if (pending.actionId && pendingInteractive.get(pending.actionId) === pending)
|
|
1824
1841
|
pendingInteractive.delete(pending.actionId);
|
|
@@ -3514,6 +3531,8 @@ export function createNotificationsExtension(
|
|
|
3514
3531
|
const sdkEnabledForSession =
|
|
3515
3532
|
(options.sdkHostModeSupported ?? true) && shouldHostSdk(settings, isNotificationEligibleContext(ctx));
|
|
3516
3533
|
const lifecycleRequired = lifecycleStartupCapability !== undefined;
|
|
3534
|
+
/** The broker-issued readiness intent for this exact lifecycle-managed session. */
|
|
3535
|
+
const lifecycleReadiness = lifecycleStartupCapability?.readiness ?? "immediate";
|
|
3517
3536
|
const failLifecycleStartup = (
|
|
3518
3537
|
reason: "disabled" | "ineligible" | "failed",
|
|
3519
3538
|
error?: unknown,
|
|
@@ -4277,10 +4296,70 @@ export function createNotificationsExtension(
|
|
|
4277
4296
|
}
|
|
4278
4297
|
};
|
|
4279
4298
|
|
|
4299
|
+
/**
|
|
4300
|
+
* Existing-thread preparation.
|
|
4301
|
+
*
|
|
4302
|
+
* A prepared session withholds its readiness signal so
|
|
4303
|
+
* `gjc notify bind-thread` can adopt an operator-supplied Slack root before
|
|
4304
|
+
* any stock root is published; activation then publishes readiness once and
|
|
4305
|
+
* the daemon adopts that root.
|
|
4306
|
+
*
|
|
4307
|
+
* Preparation has exactly two authorities, and they never overlap. A
|
|
4308
|
+
* broker lifecycle-managed session is prepared only by the broker-issued,
|
|
4309
|
+
* session-scoped readiness intent on its launch request, which the
|
|
4310
|
+
* lifecycle wait completes on the prepared signal instead of readiness. A
|
|
4311
|
+
* manual/source session keeps the explicit `GJC_NOTIFY_BIND_EXISTING_THREAD`
|
|
4312
|
+
* opt-in, which is refused for lifecycle-managed sessions so an inherited
|
|
4313
|
+
* process-global flag can never silently defer a broker-created session.
|
|
4314
|
+
*
|
|
4315
|
+
* The activation gate is the existing-thread bind authority itself: it
|
|
4316
|
+
* proves a daemon-owned mapping exists at this exact endpoint generation,
|
|
4317
|
+
* so activation before a binding fails closed with no grace period. It can
|
|
4318
|
+
* only be built from a configured, enabled Slack target plus the agent
|
|
4319
|
+
* directory holding that mapping, so a preparation request that cannot
|
|
4320
|
+
* produce one has no bind authority at all and is refused here. Degrading
|
|
4321
|
+
* such a request to ordinary immediate readiness would answer "prepare" with
|
|
4322
|
+
* a session that publishes its own root anyway, and preparing without the
|
|
4323
|
+
* gate would hand back a prepared session that activates with no binding.
|
|
4324
|
+
*/
|
|
4325
|
+
const preparationAgentDir = settings?.getAgentDir?.();
|
|
4326
|
+
const slackBindTarget =
|
|
4327
|
+
notificationsEnabledForSession && isSlackComplete(cfg)
|
|
4328
|
+
? { teamId: cfg.slack.workspaceId, channelId: cfg.slack.channelId }
|
|
4329
|
+
: undefined;
|
|
4330
|
+
const envRequestsPreparation = isExistingThreadBindingRequested(process.env);
|
|
4331
|
+
if (envRequestsPreparation && lifecycleRequired)
|
|
4332
|
+
return failLifecycleStartup(
|
|
4333
|
+
"failed",
|
|
4334
|
+
`${EXISTING_THREAD_BIND_ENV}=1 is not supported for broker lifecycle-managed sessions; use the broker readiness intent.`,
|
|
4335
|
+
);
|
|
4336
|
+
const preparesExistingThread = lifecycleRequired ? lifecycleReadiness === "deferred" : envRequestsPreparation;
|
|
4337
|
+
const activationGate =
|
|
4338
|
+
preparesExistingThread && preparationAgentDir && slackBindTarget
|
|
4339
|
+
? createSlackBindingActivationGate({
|
|
4340
|
+
store: new ConversationStore<SlackConversation>({ agentDir: preparationAgentDir, kind: "slack" }),
|
|
4341
|
+
teamId: slackBindTarget.teamId,
|
|
4342
|
+
channelId: slackBindTarget.channelId,
|
|
4343
|
+
})
|
|
4344
|
+
: undefined;
|
|
4345
|
+
if (preparesExistingThread && !activationGate) {
|
|
4346
|
+
const missing = slackBindTarget
|
|
4347
|
+
? "an agent directory"
|
|
4348
|
+
: "a configured Slack notification target for this session";
|
|
4349
|
+
if (lifecycleRequired)
|
|
4350
|
+
return failLifecycleStartup(
|
|
4351
|
+
"failed",
|
|
4352
|
+
`Existing-thread preparation requires ${missing} to prove the existing-thread binding.`,
|
|
4353
|
+
);
|
|
4354
|
+
throw new Error(`${EXISTING_THREAD_BIND_ENV}=1 requires ${missing} to prove the existing-thread binding.`);
|
|
4355
|
+
}
|
|
4356
|
+
|
|
4280
4357
|
host = new SessionSdkHost({
|
|
4281
4358
|
sessionId: id,
|
|
4282
4359
|
stateRoot,
|
|
4283
4360
|
token,
|
|
4361
|
+
...(preparesExistingThread ? { readiness: "deferred" as const } : {}),
|
|
4362
|
+
...(activationGate ? { activationGate } : {}),
|
|
4284
4363
|
sendFrame: (connectionId, frame) => sendSdkFrame(connectionId, frame),
|
|
4285
4364
|
connectionCapabilities: connectionId => hostCapCache.get(connectionId),
|
|
4286
4365
|
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
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
398
|
-
|
|
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: {
|
|
389
|
-
|
|
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
|
}
|