@parall/agent-core 1.42.0 → 1.43.0

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 (45) hide show
  1. package/dist/bin/channel-exec.d.ts +4 -0
  2. package/dist/bin/channel-exec.d.ts.map +1 -0
  3. package/dist/bin/channel-exec.js +246 -0
  4. package/dist/channel-capability.d.ts +16 -0
  5. package/dist/channel-capability.d.ts.map +1 -0
  6. package/dist/channel-capability.js +155 -0
  7. package/dist/channel-token.d.ts +19 -0
  8. package/dist/channel-token.d.ts.map +1 -0
  9. package/dist/channel-token.js +73 -0
  10. package/dist/event-format.d.ts.map +1 -1
  11. package/dist/event-format.js +21 -16
  12. package/dist/gateway-base.d.ts +15 -0
  13. package/dist/gateway-base.d.ts.map +1 -1
  14. package/dist/gateway-base.js +113 -38
  15. package/dist/gateway-lane-flow.d.ts +20 -1
  16. package/dist/gateway-lane-flow.d.ts.map +1 -1
  17. package/dist/gateway-lane-flow.js +78 -6
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -0
  21. package/dist/platform-config.d.ts +15 -0
  22. package/dist/platform-config.d.ts.map +1 -1
  23. package/dist/platform-config.js +28 -0
  24. package/dist/prompt-fragments.d.ts +1 -1
  25. package/dist/prompt-fragments.d.ts.map +1 -1
  26. package/dist/prompt-fragments.js +29 -7
  27. package/dist/skills/index.js +1 -1
  28. package/dist/skills/parall-platform.d.ts +1 -1
  29. package/dist/skills/parall-platform.d.ts.map +1 -1
  30. package/dist/skills/parall-platform.js +23 -4
  31. package/dist/types.d.ts +5 -1
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/bin/channel-exec.ts +262 -0
  35. package/src/channel-capability.ts +187 -0
  36. package/src/channel-token.ts +92 -0
  37. package/src/event-format.ts +21 -16
  38. package/src/gateway-base.ts +137 -39
  39. package/src/gateway-lane-flow.ts +92 -4
  40. package/src/index.ts +3 -0
  41. package/src/platform-config.ts +44 -0
  42. package/src/prompt-fragments.ts +29 -7
  43. package/src/skills/index.ts +1 -1
  44. package/src/skills/parall-platform.ts +23 -4
  45. package/src/types.ts +5 -1
@@ -0,0 +1,187 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import type { GatewayLogger } from './dispatch-adapter.js';
5
+ import type { AgentCapability } from './platform-config.js';
6
+
7
+ // Runtime-side materializer for channel capabilities — a pure PLACEMENT
8
+ // layer. The server declares WHAT the agent has (agents.capabilities[]);
9
+ // the credential logic lives in this package's channel-exec entry
10
+ // (bin/channel-exec.ts, reusing channel-token.ts); this module only drops
11
+ // constant POINTER shims onto the capability PATH so the vendor command name
12
+ // (e.g. `lark-cli`, which the official skills invoke directly) routes into
13
+ // that entry. Pointers reference channel-exec by IN-PACKAGE ABSOLUTE PATH —
14
+ // no PATH lookup, no dependence on agent-writable install state; the engine
15
+ // ships atomically with the bridge.
16
+ //
17
+ // Revocation keeps the pointer in place (nothing to remove): the pointer's
18
+ // content is independent of grant state, and revocation semantics are
19
+ // enforced by the platform mint endpoint (403 with an agent-readable
20
+ // message). Deleting would let PATH fall through to a directly-installed
21
+ // real CLI past the mint gate — the failure mode the old deny-stub existed
22
+ // to prevent; a constant pointer prevents it structurally.
23
+ // Design: docs/engineering-design/agent-capability-fragments-design.md §5.1.
24
+
25
+ export const CAPABILITY_FEISHU_CLI = 'feishu-cli';
26
+
27
+ // Marker embedded in every generated pointer. channel-exec skips any PATH
28
+ // candidate whose head carries it, so a pointer (or a stray copy of one) can
29
+ // never be mistaken for the real vendor binary — self-recursion is
30
+ // structurally impossible even if the skip-dir hint is wrong.
31
+ export const CHANNEL_POINTER_MAGIC = 'parall channel capability pointer';
32
+
33
+ // SSOT for the shim directory: bridges prepend this to the child PATH once,
34
+ // unconditionally — the directory is constant, its content tracks grants, so
35
+ // a grant reaches even long-lived subprocesses (the shell re-resolves PATH
36
+ // per command) without any respawn.
37
+ export function capabilityBinDir(stateDir: string): string {
38
+ return path.join(stateDir, 'bin');
39
+ }
40
+
41
+ // Absolute path of the channel-exec entry, resolved across BOTH distribution
42
+ // layouts — mirroring the daemon's resolveBbBrowserDaemonPath:
43
+ // - npm / hosted image: agent-core's dist tree exists on disk →
44
+ // ./bin/channel-exec.js sits under this module's dir.
45
+ // - standalone bundle (@parall/daemon / CDN self-update / desktop): esbuild
46
+ // flattens this module INTO bundle/parall-claude-agent.js, so ./bin/…
47
+ // doesn't exist; the bundle ships parall-channel-exec.js as a sibling flat
48
+ // artifact (scripts/bundle-daemon.mjs), resolved next to this file.
49
+ // Prefer the sibling (bundle) and fall through to the in-package path (dev /
50
+ // npm), like the bb-browser resolver — the two candidates are mutually
51
+ // exclusive so order only affects which stat wins in the impossible case that
52
+ // both exist.
53
+ export function channelExecEntryPath(): string {
54
+ const selfDir = path.dirname(fileURLToPath(import.meta.url));
55
+ const sibling = path.join(selfDir, 'parall-channel-exec.js');
56
+ if (fs.existsSync(sibling)) return sibling;
57
+ return fileURLToPath(new URL('./bin/channel-exec.js', import.meta.url));
58
+ }
59
+
60
+ /**
61
+ * Idempotently reconcile local capability pointers with the delivered
62
+ * capability list. Call at boot (after the first config fetch) and on every
63
+ * config refresh. Never throws — a pointer write failure must not take down
64
+ * a config refresh (the capability simply stays unusable until the next pass).
65
+ */
66
+ export function materializeChannelCapabilities(
67
+ stateDir: string,
68
+ capabilities: AgentCapability[],
69
+ log?: GatewayLogger,
70
+ ): void {
71
+ try {
72
+ reconcileFeishuCli(stateDir, capabilities, log);
73
+ } catch (err) {
74
+ log?.warn(`channel capability materialization failed: ${String(err)}`);
75
+ }
76
+ }
77
+
78
+ function reconcileFeishuCli(
79
+ stateDir: string,
80
+ capabilities: AgentCapability[],
81
+ log?: GatewayLogger,
82
+ ): void {
83
+ const binDir = capabilityBinDir(stateDir);
84
+ const posixPath = path.join(binDir, 'lark-cli');
85
+ const granted = capabilities.some((c) => c.key === CAPABILITY_FEISHU_CLI);
86
+ const hadPointer = fs.existsSync(posixPath);
87
+
88
+ // Write/refresh pointers when GRANTED, or when a pointer already exists (a
89
+ // previously-granted, now-revoked agent). The refresh-on-revoke case is
90
+ // load-bearing for bundle self-update: the pointer embeds channel-exec's
91
+ // ABSOLUTE path, which resolves through the daemon's `current` symlink into
92
+ // a versioned dir; after an upgrade prunes the old version, a stale retained
93
+ // pointer would fail MODULE_NOT_FOUND instead of reaching the mint 403.
94
+ // Re-rendering every pass keeps the pointer aimed at the LIVE channel-exec,
95
+ // so a revoked agent still gets the self-explanatory 403. A NEVER-granted
96
+ // agent (no pointer) is left untouched — the operator's own lark-cli install
97
+ // stays clean.
98
+ if (!granted && !hadPointer) return;
99
+
100
+ fs.mkdirSync(binDir, { recursive: true });
101
+ const entry = channelExecEntryPath();
102
+ // The node binary is referenced by ABSOLUTE path (process.execPath), not the
103
+ // bare name `node`: packaged installs (desktop / daemon bundle) embed the
104
+ // runtime as `parall-node` with no plain `node` on PATH, so a bare `node`
105
+ // would die before channel-exec ever runs and break bundle parity at the
106
+ // last hop. process.execPath is the very node currently running the bridge —
107
+ // the parall-node in a bundle, the system node under npm.
108
+ const nodeExec = process.execPath;
109
+ writePointerIfChanged(posixPath, renderPosixPointer(nodeExec, entry, binDir, 'feishu'), log);
110
+ // Windows companion: cmd/PowerShell resolve executables via PATHEXT and
111
+ // ignore extensionless shebang files. (The agent's own Bash tool on Windows
112
+ // is git-bash, which uses the sh pointer above — the .cmd is the cmd/
113
+ // PowerShell fallback; its %* follows standard batch semantics, same as any
114
+ // npm-installed .cmd bin.)
115
+ writePointerIfChanged(
116
+ path.join(binDir, 'lark-cli.cmd'),
117
+ renderCmdPointer(nodeExec, entry, binDir, 'feishu'),
118
+ log,
119
+ );
120
+ }
121
+
122
+ function writePointerIfChanged(filePath: string, content: string, log?: GatewayLogger): void {
123
+ let existing: string | null = null;
124
+ try {
125
+ existing = fs.readFileSync(filePath, 'utf8');
126
+ } catch {
127
+ existing = null;
128
+ }
129
+ if (existing !== content) {
130
+ fs.writeFileSync(filePath, content, { mode: 0o755 });
131
+ log?.info(`channel capability: pointer materialized (${path.basename(filePath)})`);
132
+ }
133
+ // Mode is enforced even when content is unchanged (a prior partial write
134
+ // or umask drift must not leave the pointer non-executable).
135
+ fs.chmodSync(filePath, 0o755);
136
+ }
137
+
138
+ // Pointer content is versioned HERE (never delivered by the server — config
139
+ // carries declarations, not code). It embeds the entry's AND the bin dir's
140
+ // absolute paths at write time — no $(dirname)/external commands (a minimal
141
+ // PATH must not break the pointer), and the skip hint cannot drift. A
142
+ // package upgrade that moves paths changes the rendered content, and the
143
+ // boot-time materialize pass rewrites it (self-healing).
144
+ // The target binary is NOT passed on the command line — channel-exec derives
145
+ // it from the channel (feishu → lark-cli), so a caller cannot redirect the
146
+ // minted token to a different program. binDir is still passed as the skip hint.
147
+ export function renderPosixPointer(
148
+ nodeExecPath: string,
149
+ entryJsPath: string,
150
+ binDir: string,
151
+ channel: string,
152
+ ): string {
153
+ return [
154
+ '#!/bin/sh',
155
+ `# Generated by @parall/agent-core — ${CHANNEL_POINTER_MAGIC} (do not edit).`,
156
+ '# Credential + exec logic lives in the agent-core package; revocation is',
157
+ '# enforced by the platform mint endpoint, so this pointer stays constant.',
158
+ // Strip Node preload-hijack vars BEFORE launching node: NODE_OPTIONS
159
+ // (e.g. --require=/evil.js) and NODE_PATH would execute caller-supplied code
160
+ // at interpreter startup — BEFORE channel-exec's own env scrub, i.e. before
161
+ // the mint. The pointer is a platform-authored trust-boundary artifact whose
162
+ // whole job is a CONTROLLED launch of the broker (absolute node, magic
163
+ // guard, skip-dir); this closes the same env-hijack class for node startup
164
+ // that the absolute node path closes for PATH, keeping the launch deterministic.
165
+ 'unset NODE_OPTIONS NODE_PATH',
166
+ // "$@" preserves argv exactly (this is the agent's main path via git-bash).
167
+ `exec "${nodeExecPath}" "${entryJsPath}" --channel ${channel} --skip-dir "${binDir}" -- "$@"`,
168
+ '',
169
+ ].join('\n');
170
+ }
171
+
172
+ export function renderCmdPointer(
173
+ nodeExecPath: string,
174
+ entryJsPath: string,
175
+ binDir: string,
176
+ channel: string,
177
+ ): string {
178
+ return [
179
+ '@echo off',
180
+ `rem Generated by @parall/agent-core - ${CHANNEL_POINTER_MAGIC} (do not edit).`,
181
+ // Clear Node preload-hijack vars before launching node (see the sh pointer).
182
+ 'set "NODE_OPTIONS="',
183
+ 'set "NODE_PATH="',
184
+ `"${nodeExecPath}" "${entryJsPath}" --channel ${channel} --skip-dir "${binDir}" -- %*`,
185
+ '',
186
+ ].join('\r\n');
187
+ }
@@ -0,0 +1,92 @@
1
+ // Channel-capability credential client — the ONE place that talks to the
2
+ // platform mint endpoint. Consumed today by the short-lived exec form
3
+ // (bin/channel-exec.ts); the future resident forms (proxy / mcp, hosted by
4
+ // the bridge process) reuse this module so swimlane routing, error
5
+ // presentation, and any future retry/telemetry policy stay single-sourced.
6
+ // Design: docs/engineering-design/agent-capability-fragments-design.md §5.
7
+
8
+ export interface MintedChannelToken {
9
+ channel_type: string;
10
+ token_type: string;
11
+ app_id: string;
12
+ brand: string;
13
+ token: string;
14
+ expires_at: string;
15
+ }
16
+
17
+ // Thrown for every mint failure; `message` is written for the AGENT to read
18
+ // (and relay to the user when the platform says the capability is revoked).
19
+ export class ChannelTokenError extends Error {}
20
+
21
+ const MINT_TIMEOUT_MS = 10_000;
22
+
23
+ /**
24
+ * Exchange the agent's channel grant for a short-lived provider token.
25
+ * Deliberately NO local caching at any layer here: the parall round trip is
26
+ * tens of ms (the expensive vendor exchange is absorbed by the server-side
27
+ * Redis cache), and every call re-passing the server gate is what makes
28
+ * revocation and re-credentialing bite on the very next invocation.
29
+ */
30
+ export async function mintChannelToken(
31
+ channelType: string,
32
+ env: NodeJS.ProcessEnv,
33
+ ): Promise<MintedChannelToken> {
34
+ const apiUrl = (env.PRLL_API_URL || '').replace(/\/+$/, '');
35
+ const apiKey = env.PRLL_API_KEY || '';
36
+ const orgId = env.PRLL_ORG_ID || '';
37
+ if (!apiUrl || !apiKey || !orgId) {
38
+ throw new ChannelTokenError(
39
+ 'PRLL_API_URL/PRLL_API_KEY/PRLL_ORG_ID missing from the environment',
40
+ );
41
+ }
42
+ const headers: Record<string, string> = {
43
+ 'Content-Type': 'application/json',
44
+ Authorization: `Bearer ${apiKey}`,
45
+ };
46
+ // Swimlane routing must ride the mint request exactly like every SDK call —
47
+ // a bridge deployed in a PR swimlane mints against its own API/DB.
48
+ const swimlane = (env.PRLL_SWIMLANE_NAME || '').trim();
49
+ if (swimlane) headers['X-Prll-Swimlane'] = swimlane;
50
+
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(() => controller.abort(), MINT_TIMEOUT_MS);
53
+ let resp: Response;
54
+ try {
55
+ resp = await fetch(
56
+ `${apiUrl}/api/v1/orgs/${encodeURIComponent(orgId)}/agents/me/channel-token`,
57
+ {
58
+ method: 'POST',
59
+ headers,
60
+ body: JSON.stringify({ channel_type: channelType }),
61
+ signal: controller.signal,
62
+ },
63
+ );
64
+ } catch (err) {
65
+ throw new ChannelTokenError(
66
+ `could not reach the Parall platform to authenticate (${String(err)}); channel actions are unavailable right now`,
67
+ );
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+
72
+ if (!resp.ok) {
73
+ let msg = `platform returned HTTP ${resp.status}`;
74
+ try {
75
+ const body = (await resp.json()) as { error?: { message?: string } };
76
+ if (body?.error?.message) msg = body.error.message;
77
+ } catch {
78
+ // non-JSON error body: keep the status-line message
79
+ }
80
+ throw new ChannelTokenError(msg);
81
+ }
82
+
83
+ // Validate every field the caller actually injects into the vendor CLI, not
84
+ // just `token`: a 2xx missing app_id/brand would otherwise launch the real
85
+ // CLI with an incomplete credential env and fail opaquely downstream.
86
+ const minted = (await resp.json()) as Partial<MintedChannelToken>;
87
+ const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
88
+ if (!minted || !nonEmpty(minted.token) || !nonEmpty(minted.app_id) || !nonEmpty(minted.brand)) {
89
+ throw new ChannelTokenError('platform returned an unusable token payload');
90
+ }
91
+ return minted as MintedChannelToken;
92
+ }
@@ -175,22 +175,27 @@ function buildSendMessageHint(event: ParallEvent): string {
175
175
  }
176
176
 
177
177
  if (event.type === 'channel_message') {
178
- // Provider metadata is best-effort (the gateway's connection lookup can
179
- // fail) never instruct the agent to invoke a made-up clip alias.
180
- const clipLabel = event.channelProvider
181
- ? `the \`${event.channelProvider}\` clip's`
182
- : "your channel provider clip's";
183
- const apiLabel = event.channelProvider ?? 'external platform';
184
- const target = event.channelExternalConversationId
185
- ? `{"chat_id": "${event.channelExternalConversationId}", "text": "..."}`
186
- : `{"chat_id": "<conversation id>", "text": "..."}`;
187
- // Offer the in-thread alternative whenever the inbound message id is
188
- // known — otherwise the hint nudges every threaded conversation toward a
189
- // new top-level message.
190
- const threadAlt = event.channelExternalMessageId
191
- ? ` To reply in-thread to this specific message, use {"message_id": "${event.channelExternalMessageId}", "text": "..."} instead.`
192
- : '';
193
- return `\n<system-reminder>To reply, invoke ${clipLabel} \`send_message\` command with ${target} — your plain text output is NOT delivered to the external conversation.${threadAlt} The same clip's \`call\` command reaches the wider ${apiLabel} API when needed.</system-reminder>`;
178
+ // Single-path routing (multi-channel-architecture-design §6): with the
179
+ // `<provider>-cli` capability granted, the vendor CLI on PATH is THE
180
+ // reply path; without it there is no outbound path at all — say so
181
+ // instead of pointing at the retired provider clip.
182
+ // channelCliCapable alone decides: only feishu mints exist today, so a
183
+ // live grant implies Feishu even when the cosmetic provider-label lookup
184
+ // failed (event.channelProvider undefined).
185
+ if (event.channelCliCapable) {
186
+ const convRef = event.channelExternalConversationId
187
+ ? `chat_id "${event.channelExternalConversationId}"`
188
+ : 'the conversation id named in this event';
189
+ // Offer the in-thread alternative whenever the inbound message id is
190
+ // known otherwise the hint nudges every threaded conversation toward
191
+ // a new top-level message.
192
+ const threadAlt = event.channelExternalMessageId
193
+ ? ` To reply threaded to this specific message, reference message_id "${event.channelExternalMessageId}".`
194
+ : '';
195
+ return `\n<system-reminder>To reply, use the official Feishu CLI on your PATH: send a message to ${convRef} with \`lark-cli im\` (see \`lark-cli im --help\` for send syntax; auth is provisioned automatically).${threadAlt} lark-cli is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
196
+ }
197
+ const platform = event.channelProvider ?? 'the external platform';
198
+ return `\n<system-reminder>This message arrived from ${platform}, but outbound replies are currently disabled for this org (no channel capability granted). Do NOT attempt to reply on the external platform. If action is needed, surface it inside Parall (\`parall messages send\` / \`parall dm\`). Your plain text output is not delivered anywhere.</system-reminder>`;
194
199
  }
195
200
 
196
201
  if (event.type === 'external_trigger' || event.targetId.startsWith('xtr_')) {
@@ -148,6 +148,14 @@ export type ParallGatewayOptions = {
148
148
  * this directory. Absent → legacy received/ack flow (openclaw / hermes).
149
149
  */
150
150
  dispatchContextDir?: string;
151
+ /**
152
+ * Live view of the agent's platform-granted capability keys (bridges wire
153
+ * PlatformConfigManager.capabilities().map(c => c.key)). Read at
154
+ * channel-event build time so the reply hint routes to the capability
155
+ * affordance (e.g. feishu-cli → lark-cli) — the single outbound path.
156
+ * Absent/empty → the hint states outbound is disabled.
157
+ */
158
+ getCapabilityKeys?: () => string[];
151
159
  onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
152
160
  onSessionReady?: (state: {
153
161
  activeSessionId?: string;
@@ -293,6 +301,9 @@ export class ParallAgentGateway {
293
301
  // (stable mapping; avoids one connection fetch per inbound message).
294
302
  private readonly channelConnectionProviders = new Map<string, string>();
295
303
  private readonly dispatchedMessages = new Set<string>();
304
+ // Per-WorkItem failure backoff for typed dispatch consumption — see
305
+ // LaneFlowHost.typedRedriveBackoff in gateway-lane-flow.ts.
306
+ readonly typedRedriveBackoff = new Map<string, { failures: number; until: number }>();
296
307
  private readonly forkStates = new Map<string, ActiveForkState>();
297
308
  private readonly dispatchState: DispatchState = {
298
309
  mainDispatching: false,
@@ -438,17 +449,28 @@ export class ParallAgentGateway {
438
449
  (dispatchEventId) => this.handleTaskAssignment(data, data.id, dispatchEventId),
439
450
  (dispatchEventId) => {
440
451
  if (dispatchEventId) {
441
- this.opts.client
442
- .ackDispatchByID(this.opts.config.org_id, dispatchEventId)
443
- .catch(() => {});
444
- return;
452
+ return this.ackDispatchEvent(dispatchEventId, () => {
453
+ this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
454
+ });
445
455
  }
446
- this.opts.client
456
+ return this.opts.client
447
457
  .ackDispatch(this.opts.config.org_id, {
448
458
  source_type: 'task_activity',
449
459
  source_id: data.id,
450
460
  })
451
- .catch(() => {});
461
+ .then(
462
+ () => true,
463
+ (err) => {
464
+ // Same contract as ackDispatchEvent: a failed ack is a
465
+ // failed consume (arms backoff) and must free the hot-path
466
+ // dedupe so the re-drive isn't rejected by this pod forever.
467
+ this.dispatchedTasks.delete(`${data.id}:${data.updated_at}`);
468
+ this.opts.log?.warn(
469
+ `dispatch ack failed for task ${data.id}, releasing for re-drive: ${String(err)}`,
470
+ );
471
+ return false;
472
+ },
473
+ );
452
474
  },
453
475
  );
454
476
  } catch (err) {
@@ -457,7 +479,16 @@ export class ParallAgentGateway {
457
479
  });
458
480
 
459
481
  ws.on('dispatch.new', async (data: DispatchNewData) => {
460
- if (data.event_type === 'task_comment') {
482
+ if (data.event_type === 'task_assign') {
483
+ if (!data.task_id) return;
484
+ try {
485
+ await this.handleTaskAssignmentRedrive(data);
486
+ } catch (err) {
487
+ this.opts.log?.error(
488
+ `task assignment re-drive failed for ${data.task_id}: ${String(err)}`,
489
+ );
490
+ }
491
+ } else if (data.event_type === 'task_comment') {
461
492
  if (!data.source_id || !data.task_id) return;
462
493
  try {
463
494
  await this.consumeTypedDispatch(
@@ -469,9 +500,7 @@ export class ParallAgentGateway {
469
500
  data.actor_id,
470
501
  data.delivery_reason,
471
502
  ),
472
- () => {
473
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
474
- },
503
+ () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
475
504
  );
476
505
  } catch (err) {
477
506
  this.opts.log?.error(
@@ -484,9 +513,7 @@ export class ParallAgentGateway {
484
513
  await this.consumeTypedDispatch(
485
514
  { dispatchEventId: data.id },
486
515
  () => this.handleWikiComment(data.source_id, data.actor_id, data.delivery_reason),
487
- () => {
488
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
489
- },
516
+ () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
490
517
  );
491
518
  } catch (err) {
492
519
  this.opts.log?.error(
@@ -503,9 +530,7 @@ export class ParallAgentGateway {
503
530
  allowCreator: true,
504
531
  dispatchEventId,
505
532
  }),
506
- () => {
507
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
508
- },
533
+ () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
509
534
  );
510
535
  } catch (err) {
511
536
  this.opts.log?.error(`task update dispatch failed for ${data.task_id}: ${String(err)}`);
@@ -516,9 +541,7 @@ export class ParallAgentGateway {
516
541
  await this.consumeTypedDispatch(
517
542
  { dispatchEventId: data.id },
518
543
  () => this.fetchAndHandleScheduleFire(data.source_id, data.actor_id),
519
- () => {
520
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
521
- },
544
+ () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
522
545
  );
523
546
  } catch (err) {
524
547
  this.opts.log?.error(
@@ -531,9 +554,7 @@ export class ParallAgentGateway {
531
554
  await this.consumeTypedDispatch(
532
555
  { dispatchEventId: data.id },
533
556
  () => this.fetchAndHandleExternalTriggerRun(data.source_id),
534
- () => {
535
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
536
- },
557
+ () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
537
558
  );
538
559
  } catch (err) {
539
560
  this.opts.log?.error(
@@ -546,9 +567,7 @@ export class ParallAgentGateway {
546
567
  await this.consumeTypedDispatch(
547
568
  { dispatchEventId: data.id },
548
569
  () => this.fetchAndHandleChannelMessage(data.source_id),
549
- () => {
550
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
551
- },
570
+ () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
552
571
  );
553
572
  } catch (err) {
554
573
  this.opts.log?.error(
@@ -566,9 +585,7 @@ export class ParallAgentGateway {
566
585
  data.actor_id,
567
586
  data.chat_id ?? null,
568
587
  ),
569
- () => {
570
- this.opts.client.ackDispatchByID(this.opts.config.org_id, data.id).catch(() => {});
571
- },
588
+ () => this.ackDispatchEvent(data.id, () => this.clearTypedDispatchDedupe(data)),
572
589
  );
573
590
  } catch (err) {
574
591
  this.opts.log?.error(
@@ -589,14 +606,11 @@ export class ParallAgentGateway {
589
606
  } catch (err) {
590
607
  this.opts.log?.error(`message re-drive failed for ${data.source_id}: ${String(err)}`);
591
608
  }
592
- } else if (data.event_type !== 'message' && data.event_type !== 'task_assign') {
609
+ } else if (data.event_type !== 'message') {
593
610
  // Truly unknown event_type — log so a newly-added dispatch type
594
- // not yet wired here surfaces during runtime testing. "message"
595
- // and "task_assign" are deliberately excluded: dispatch.new
596
- // carries them too, but they are owned by dedicated WS handlers
597
- // (message.new, task.assigned) above and would otherwise spam
598
- // info-level logs for every inbound chat message / task
599
- // assignment on a busy agent.
611
+ // not yet wired here surfaces during runtime testing. "message" is
612
+ // deliberately excluded because its first delivery is owned by the
613
+ // message.new handler; recovered task_assign rows are handled above.
600
614
  this.opts.log?.info(
601
615
  `dispatch.new with unhandled event_type=${String(data.event_type)} (id=${data.id}) — no-op`,
602
616
  );
@@ -686,11 +700,63 @@ export class ParallAgentGateway {
686
700
  private consumeTypedDispatch(
687
701
  ref: { dispatchEventId?: string; sourceType?: string; sourceId?: string },
688
702
  run: (dispatchEventId?: string) => Promise<boolean>,
689
- ack: (dispatchEventId?: string) => void,
703
+ ack: (dispatchEventId?: string) => boolean | void | Promise<boolean | void>,
690
704
  ): Promise<void> {
691
705
  return consumeTypedDispatch(this.laneFlowHost(), ref, run, ack);
692
706
  }
693
707
 
708
+ // Typed completion must wait until the administrative ack has either
709
+ // committed or failed. Errors stay best-effort: a failed ack leaves the row
710
+ // received, so Complete releases and re-drives it safely. The boolean
711
+ // outcome feeds the typed-consume backoff — an ack that failed must count
712
+ // as a failed consume, or an ack outage would clear the backoff entry and
713
+ // let the release re-drive spin at wire speed.
714
+ private ackDispatchEvent(dispatchEventId: string, onFailure?: () => void): Promise<boolean> {
715
+ return this.opts.client.ackDispatchByID(this.opts.config.org_id, dispatchEventId).then(
716
+ () => true,
717
+ (err) => {
718
+ // Complete will return the still-received item to pending and publish
719
+ // an immediate hint. Free its hot-path claim first, otherwise that
720
+ // same-process re-drive is rejected forever by the local dedupe set.
721
+ onFailure?.();
722
+ this.opts.log?.warn(
723
+ `dispatch ack failed for ${dispatchEventId}, releasing for re-drive: ${String(err)}`,
724
+ );
725
+ return false;
726
+ },
727
+ );
728
+ }
729
+
730
+ private clearTypedDispatchDedupe(item: DispatchNewData): void {
731
+ switch (item.event_type) {
732
+ case 'task_assign':
733
+ case 'task_update':
734
+ if (item.task_id) {
735
+ const prefix = `${item.task_id}:`;
736
+ for (const key of this.dispatchedTasks) {
737
+ if (key.startsWith(prefix)) this.dispatchedTasks.delete(key);
738
+ }
739
+ }
740
+ break;
741
+ case 'task_comment':
742
+ case 'wiki_comment':
743
+ if (item.source_id) this.dispatchedTasks.delete(`comment:${item.source_id}`);
744
+ break;
745
+ case 'schedule.fire':
746
+ if (item.source_id) this.dispatchedTasks.delete(`schedule_run:${item.source_id}`);
747
+ break;
748
+ case 'external_trigger':
749
+ if (item.source_id) this.dispatchedTasks.delete(`external_trigger_run:${item.source_id}`);
750
+ break;
751
+ case 'channel_message':
752
+ if (item.source_id) this.dispatchedMessages.delete(`channel_message:${item.source_id}`);
753
+ break;
754
+ case 'approval_decided':
755
+ if (item.source_id) this.dispatchedTasks.delete(`approval:${item.source_id}`);
756
+ break;
757
+ }
758
+ }
759
+
694
760
  private buildDispatchContext(event: ParallEvent, sessionKey: string): DispatchContext {
695
761
  const binding = this.sessionBindings.get(sessionKey);
696
762
  return {
@@ -2023,6 +2089,25 @@ export class ParallAgentGateway {
2023
2089
  });
2024
2090
  }
2025
2091
 
2092
+ // A task assignment's first delivery rides task.assigned, but a typed lane
2093
+ // released without an effect is re-driven as dispatch.new. Reuse the exact
2094
+ // typed claim path used by catch-up so that recovery does not require a
2095
+ // runtime reconnect.
2096
+ private async handleTaskAssignmentRedrive(item: DispatchNewData): Promise<void> {
2097
+ if (!item.task_id) return;
2098
+ await this.consumeTypedDispatch(
2099
+ { dispatchEventId: item.id },
2100
+ (dispatchEventId) =>
2101
+ this.handleTaskDispatch(item.task_id ?? '', item.source_id ?? item.task_id ?? '', {
2102
+ dispatchEventId,
2103
+ }),
2104
+ (dispatchEventId) =>
2105
+ this.ackDispatchEvent(dispatchEventId ?? item.id, () =>
2106
+ this.clearTypedDispatchDedupe(item),
2107
+ ),
2108
+ );
2109
+ }
2110
+
2026
2111
  private consumeMessageWorkItem(item: {
2027
2112
  id: string;
2028
2113
  source_id: string;
@@ -2410,6 +2495,19 @@ export class ParallAgentGateway {
2410
2495
  }
2411
2496
  }
2412
2497
 
2498
+ // The reply hint routes on the live capability grant: `<provider>-cli`
2499
+ // present → the vendor CLI is on PATH (broker shim) and is THE reply
2500
+ // path; absent → outbound is disabled for this org (flag/connection off)
2501
+ // and the hint must say so instead of pointing at a retired clip. The
2502
+ // provider label lookup above is best-effort/cosmetic — when it fails,
2503
+ // ANY granted `*-cli` capability keeps the hint on the CLI path: a
2504
+ // transient metadata miss must not flip an actively granted agent's
2505
+ // hint to "outbound disabled" and strand a valid external message.
2506
+ const keys = this.opts.getCapabilityKeys?.() ?? [];
2507
+ const cliCapable = provider
2508
+ ? keys.includes(`${provider}-cli`)
2509
+ : keys.some((k) => k.endsWith('-cli'));
2510
+
2413
2511
  const event: ParallEvent = {
2414
2512
  type: 'channel_message',
2415
2513
  targetId: conv.id,
@@ -2424,6 +2522,7 @@ export class ParallAgentGateway {
2424
2522
  channelConversationType: conv.conversation_type || undefined,
2425
2523
  channelExternalConversationId: conv.external_conversation_id,
2426
2524
  channelExternalMessageId: msg.external_message_id,
2525
+ channelCliCapable: cliCapable,
2427
2526
  ackSourceType: 'channel_message',
2428
2527
  ackSourceId: msg.id,
2429
2528
  };
@@ -2596,9 +2695,8 @@ export class ParallAgentGateway {
2596
2695
 
2597
2696
  processed++;
2598
2697
  try {
2599
- const ackItem = () => {
2600
- this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
2601
- };
2698
+ const ackItem = () =>
2699
+ this.ackDispatchEvent(item.id, () => this.clearTypedDispatchDedupe(item));
2602
2700
  if (item.event_type === 'task_assign' && item.task_id) {
2603
2701
  try {
2604
2702
  await this.consumeTypedDispatch(