@parall/parel-channel 1.42.1 → 1.44.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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Channel-capability credential injection — the parel-native form of the
3
+ * feishu-cli broker (multi-channel-architecture-design §8.3).
4
+ *
5
+ * Bridges deliver the capability as a PATH pointer shim that mints a fresh
6
+ * short-lived tenant token per lark-cli invocation. In parel we control
7
+ * neither the sandbox PATH nor a bridge process — but we DO control the
8
+ * per-turn invocation context, which sandbox-e2b flattens into every exec's
9
+ * env. So the connector mints at emit time and injects the official
10
+ * LARKSUITE_CLI_* env set: bare `lark-cli` works ambiently for the whole
11
+ * turn (turn length ≪ token TTL ~2h), the app secret never leaves the
12
+ * platform, and revocation bites on the next emit's mint (403 → no env).
13
+ *
14
+ * The mint endpoint IS the gate (the platform re-evaluates connection state
15
+ * + flag on every request — the no-drift invariant), so the connector needs
16
+ * no declaration-plane fetch: a 403 is the revocation signal and is
17
+ * negative-cached briefly to keep a revoked grant from adding one 403 per
18
+ * turn.
19
+ */
20
+ import type { ConnectorContext } from '@parel/plugin-sdk';
21
+ import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
22
+
23
+ const MINT_TIMEOUT_MS = 5_000;
24
+ // Re-mint this long before the token expires so an env set injected at turn
25
+ // start stays valid through a long turn's tail.
26
+ const EXPIRY_SKEW_MS = 10 * 60 * 1000;
27
+ // After a 403 (no grant / revoked / flag off), skip minting for this long —
28
+ // revocation latency stays bounded while per-turn 403 noise does not.
29
+ const DENIED_TTL_MS = 60 * 1000;
30
+ // A GRANTED result is re-validated on the same cadence: the mint endpoint IS
31
+ // the revocation gate, so a positive cache lasting the token TTL (~2h) would
32
+ // extend revocation latency to hours. Re-minting is cheap (the platform
33
+ // Redis-caches the exchanged token and re-evaluates the gate per request);
34
+ // this cache only absorbs bursty emits within a conversation.
35
+ const GRANTED_TTL_MS = 60 * 1000;
36
+
37
+ interface MintedToken {
38
+ app_id: string;
39
+ brand: string;
40
+ token: string;
41
+ expires_at: string;
42
+ }
43
+
44
+ interface CacheEntry {
45
+ env: Record<string, string> | null; // null = denied (negative cache)
46
+ validUntil: number;
47
+ }
48
+
49
+ // Per-connection cache. The plugin isolate is per-connection, so a plain
50
+ // module map keyed by connectionId is effectively single-entry — the key
51
+ // guards against isolate reuse across connections.
52
+ const cache = new Map<string, CacheEntry>();
53
+
54
+ /** Test hook: the cache is module-level state, so suites reset it between cases. */
55
+ export function resetChannelCapabilityCache(): void {
56
+ cache.clear();
57
+ }
58
+
59
+ function envFromMint(minted: MintedToken): Record<string, string> {
60
+ return {
61
+ LARKSUITE_CLI_APP_ID: minted.app_id,
62
+ LARKSUITE_CLI_BRAND: minted.brand || 'feishu',
63
+ LARKSUITE_CLI_TENANT_ACCESS_TOKEN: minted.token,
64
+ // Bot lock: the credential is the app's, not a human's — mirror the
65
+ // bridge shim's injection exactly (agent-core channel-exec).
66
+ LARKSUITE_CLI_DEFAULT_AS: 'bot',
67
+ LARKSUITE_CLI_STRICT_MODE: 'bot',
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Returns the feishu-cli env set to spread into an envelope's invocation
73
+ * context, or null when the capability is not granted (or the mint degraded
74
+ * — a transient platform error must never block a dispatch emit).
75
+ */
76
+ export async function channelCapabilityEnv(
77
+ ctx: ConnectorContext,
78
+ ): Promise<Record<string, string> | null> {
79
+ const key = ctx.connectionId;
80
+ const now = Date.now();
81
+ const hit = cache.get(key);
82
+ if (hit && hit.validUntil > now) return hit.env;
83
+
84
+ try {
85
+ const res = await fetch(
86
+ `${parallApiUrl(ctx)}/api/v1/orgs/${encodeURIComponent(parallOrgId(ctx))}/agents/me/channel-token`,
87
+ {
88
+ method: 'POST',
89
+ headers: {
90
+ authorization: `Bearer ${requireAgk(ctx)}`,
91
+ 'content-type': 'application/json',
92
+ },
93
+ body: JSON.stringify({ channel_type: 'feishu' }),
94
+ signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
95
+ },
96
+ );
97
+ if (res.status === 403 || res.status === 404) {
98
+ // No grant (never had it / revoked / flag off) — or an old server
99
+ // without the endpoint. Same agent-facing outcome: no capability.
100
+ cache.set(key, { env: null, validUntil: now + DENIED_TTL_MS });
101
+ return null;
102
+ }
103
+ if (!res.ok) {
104
+ // Transient (5xx/502-mint-upstream): don't cache, don't block the emit.
105
+ return null;
106
+ }
107
+ const minted = (await res.json()) as MintedToken;
108
+ if (!minted?.token || !minted.app_id) return null;
109
+ const env = envFromMint(minted);
110
+ const expiresAt = Date.parse(minted.expires_at);
111
+ // Bounded by BOTH the revalidation cadence (revocation latency) and the
112
+ // token's own remaining life (an injected env must outlive the turn).
113
+ const tokenBound = Number.isFinite(expiresAt) ? expiresAt - EXPIRY_SKEW_MS : now;
114
+ const validUntil = Math.max(now + 5_000, Math.min(now + GRANTED_TTL_MS, tokenBound));
115
+ cache.set(key, { env, validUntil });
116
+ return env;
117
+ } catch {
118
+ // Network failure — degrade to "no env this turn"; the next emit retries.
119
+ return null;
120
+ }
121
+ }
@@ -16,15 +16,15 @@
16
16
  *
17
17
  * - The audience is OUTSIDE Parall — no prll:// refs, no internal cards or
18
18
  * attachment links in the reply (dead links there).
19
- * - The reply goes out through the provider clip's `send_message` command.
20
- * A parel agent invokes clips from its sandbox via the parall CLI, so the
21
- * hint spells out the concrete `parall clip invoke …` command (agent-core
22
- * leaves the invocation mechanism to the bridge's own clip tooling).
23
- * - Provider metadata is best-effort (the connection lookup can fail) —
24
- * never instruct the agent to invoke a made-up clip alias.
19
+ * - Single-path reply (multi-channel-architecture-design §6): with the
20
+ * feishu-cli capability granted, the reply goes out through the official
21
+ * lark-cli — auth rides the turn's invocation context env
22
+ * (channel-capability.ts), so bare `lark-cli` works in the sandbox.
23
+ * Without the grant there is no outbound path and the hint says so (the
24
+ * provider clip path is retired).
25
25
  * - When the inbound external message id is known, offer the in-thread reply
26
- * form ({"message_id": …}) so threaded conversations aren't nudged toward
27
- * new top-level messages.
26
+ * form so threaded conversations aren't nudged toward new top-level
27
+ * messages.
28
28
  */
29
29
 
30
30
  function sanitizeMeta(value: string): string {
@@ -90,7 +90,7 @@ export function buildChatPrompt(args: ChatPromptArgs): string {
90
90
  }
91
91
 
92
92
  export interface ChannelPromptArgs {
93
- /** Provider name (= clip alias, e.g. "feishu"); undefined when unresolved. */
93
+ /** Provider name (e.g. "feishu"); undefined when unresolved. */
94
94
  provider?: string;
95
95
  /** Conversation shape as recorded on the ChannelConversation (dm/group). */
96
96
  conversationType?: string;
@@ -99,6 +99,13 @@ export interface ChannelPromptArgs {
99
99
  senderName: string;
100
100
  /** The inbound message text (raw, un-framed). */
101
101
  text: string;
102
+ /**
103
+ * Live capability grant: the turn's invocation context carries the minted
104
+ * LARKSUITE_CLI_* env, so bare `lark-cli` works in the sandbox and is THE
105
+ * reply path. False → outbound is disabled for this org (no grant); the
106
+ * hint says so instead of pointing at the retired provider clip.
107
+ */
108
+ cliCapable?: boolean;
102
109
  }
103
110
 
104
111
  export function buildChannelPrompt(args: ChannelPromptArgs): string {
@@ -122,16 +129,22 @@ export function buildChannelPrompt(args: ChannelPromptArgs): string {
122
129
  }
123
130
 
124
131
  function buildChannelReplyHint(args: ChannelPromptArgs): string {
125
- const target = JSON.stringify({
126
- chat_id: args.externalConversationId || '<conversation id>',
127
- text: '...',
128
- });
129
- const threadAlt = args.externalMessageId
130
- ? ` To reply in-thread to this specific message, use '${JSON.stringify({ message_id: args.externalMessageId, text: '...' })}' instead.`
131
- : '';
132
- if (args.provider) {
133
- const alias = args.provider;
134
- return `\n<system-reminder>To reply, invoke the \`${alias}\` clip's \`send_message\` command from your sandbox: \`parall clip invoke ${alias} send_message '${target}'\` — your plain text output is NOT delivered to the external conversation.${threadAlt} The same clip's \`call\` command reaches the wider ${alias} API when needed.</system-reminder>`;
132
+ // Single-path routing (multi-channel-architecture-design §6/§8.3): with
133
+ // the feishu-cli grant, the minted LARKSUITE_CLI_* env rides this turn's
134
+ // invocation context, so the official lark-cli works ambiently in the
135
+ // sandbox and is THE reply path. Without the grant there is no outbound
136
+ // path — say so instead of pointing at the retired provider clip.
137
+ // cliCapable alone decides: the mint is feishu-specific, so a successful
138
+ // grant implies Feishu even when the cosmetic provider-label lookup failed.
139
+ if (args.cliCapable) {
140
+ const convRef = args.externalConversationId
141
+ ? `chat_id "${args.externalConversationId}"`
142
+ : 'the conversation id named in this event';
143
+ const threadAlt = args.externalMessageId
144
+ ? ` To reply threaded to this specific message, reference message_id "${args.externalMessageId}".`
145
+ : '';
146
+ return `\n<system-reminder>To reply, use the official Feishu CLI in your sandbox: send a message to ${convRef} with \`lark-cli im\` (see \`lark-cli im --help\` for send syntax; auth is provisioned in your environment — if lark-cli is missing, install it once with \`npm i -g @larksuite/cli\`).${threadAlt} lark-cli is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
135
147
  }
136
- return `\n<system-reminder>To reply, invoke your channel provider clip's \`send_message\` command from your sandbox: \`parall clip invoke <alias> send_message '${target}'\` (find the alias with \`parall clip list\`) — your plain text output is NOT delivered to the external conversation.${threadAlt} The same clip's \`call\` command reaches the wider external platform API when needed.</system-reminder>`;
148
+ const platform = args.provider ?? 'the external platform';
149
+ 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>`;
137
150
  }
package/src/delivery.ts CHANGED
@@ -99,7 +99,7 @@ export async function buildParallDelivery(
99
99
  // ON and observe OFF — the platform template always sets both together,
100
100
  // so it exists only on a hand-crafted binding.
101
101
  if (route.envelopeId) {
102
- await completeFencedEnvelopes(ctx, [route.envelopeId]);
102
+ await completeFencedEnvelopes(ctx, [route.envelopeId], 'ok');
103
103
  }
104
104
 
105
105
  const subject = route.chatId ?? route.conversationId;
package/src/fork.ts CHANGED
@@ -1,5 +1,7 @@
1
- import type { ConnectorContext } from '@parel/plugin-sdk';
2
- import type { AgentEventWithChild, ChildSpawnFailedEvent } from './parel-sdk-compat.js';
1
+ import type { AgentEvent, ConnectorContext } from '@parel/plugin-sdk';
2
+
3
+ /** The child-spawn failure variant of the sdk's AgentEvent union. */
4
+ type ChildSpawnFailedEvent = Extract<AgentEvent, { type: 'child_spawn_failed' }>;
3
5
 
4
6
  /**
5
7
  * Fork-on-busy scheduling (the parall-side half of parel F4, parel-mono #140;
@@ -298,10 +300,7 @@ export async function clearForkState(ctx: ConnectorContext): Promise<string[]> {
298
300
  * Track main-session busyness from the E1 turn stream. Child events (they
299
301
  * carry childRef) must NOT feed this — only the main session's turns do.
300
302
  */
301
- export async function noteMainTurnEvent(
302
- ctx: ConnectorContext,
303
- event: AgentEventWithChild,
304
- ): Promise<void> {
303
+ export async function noteMainTurnEvent(ctx: ConnectorContext, event: AgentEvent): Promise<void> {
305
304
  if (event.type === 'turn_started') {
306
305
  const row: MainTurnRow = { turnId: event.turnId, subject: event.subject, at: ctx.now() };
307
306
  await ctx.store?.set(MAIN_TURN_KEY, row).catch(() => {});
@@ -323,7 +322,7 @@ export async function noteMainTurnEvent(
323
322
  */
324
323
  export async function settleChildEvent(
325
324
  ctx: ConnectorContext,
326
- event: AgentEventWithChild,
325
+ event: AgentEvent,
327
326
  ): Promise<{ subject: string; pendingAcks: PendingAck[] } | null> {
328
327
  const childRef = event.childRef;
329
328
  if (!childRef) return null;
@@ -1,12 +1,13 @@
1
1
  import type { ConnectorContext, ConnectorEffect } from '@parel/plugin-sdk';
2
+ import { channelCapabilityEnv } from './channel-capability.js';
2
3
  import { buildChannelPrompt } from './channel-prompt.js';
3
4
  import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
4
5
  import {
5
- ackEffect,
6
+ claimDispatchSource,
7
+ completeSourceEffect,
6
8
  createChannelInputStep,
7
9
  ensureSession,
8
10
  invalidateSession,
9
- markDispatchReceived,
10
11
  nextEnvelopeId,
11
12
  patchSession,
12
13
  readActiveEmit,
@@ -61,7 +62,7 @@ export async function effectsForChannelDispatch(
61
62
  if (m.status === 404 || m.status === 403) {
62
63
  // Deleted / inaccessible (also the shape a disabled org feature flag
63
64
  // takes) — permanently unusable, drop it.
64
- return [ackEffect(ctx, 'channel_message', sourceId)];
65
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
65
66
  }
66
67
  return []; // transient: keep pending, the catch-up sweep retries
67
68
  }
@@ -77,19 +78,19 @@ export async function effectsForChannelDispatch(
77
78
  const text = msg?.text ?? '';
78
79
  if (!conversationId) {
79
80
  // No conversation → no session key and no reply target: unusable.
80
- return [ackEffect(ctx, 'channel_message', sourceId)];
81
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
81
82
  }
82
83
  if (!text) {
83
84
  // The ingress adapter only persists mechanically parsed text; a row with
84
85
  // none will never grow any (same reasoning as attachment-only chat
85
86
  // messages) — drop rather than let it pile up in the pending queue.
86
- return [ackEffect(ctx, 'channel_message', sourceId)];
87
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
87
88
  }
88
89
 
89
90
  const c = await get(`/channel-conversations/${conversationId}`);
90
91
  if (!c.ok) {
91
92
  if (c.status === 404 || c.status === 403) {
92
- return [ackEffect(ctx, 'channel_message', sourceId)];
93
+ return [completeSourceEffect(ctx, 'channel_message', sourceId)];
93
94
  }
94
95
  return [];
95
96
  }
@@ -114,7 +115,7 @@ export async function effectsForChannelDispatch(
114
115
  // (see the comments there): received settles before this function returns;
115
116
  // the turnEnvelope marker goes down before any session write; the row
116
117
  // stays 'received' until the turn event's complete-sources.
117
- const receivedSettled = markDispatchReceived(ctx, 'channel_message', sourceId);
118
+ const receivedSettled = claimDispatchSource(ctx, 'channel_message', sourceId);
118
119
  const priorEmit = await readActiveEmit(ctx, 'channel_message', sourceId);
119
120
  const envelopeId = nextEnvelopeId('channel_message', sourceId, priorEmit);
120
121
  let sessionId = await ensureSession(ctx);
@@ -154,6 +155,10 @@ export async function effectsForChannelDispatch(
154
155
  await receivedSettled;
155
156
  await writeActiveEmit(ctx, 'channel_message', sourceId, envelopeId);
156
157
 
158
+ // Live capability grant → this turn's lark-cli auth env (mint gate = the
159
+ // no-drift revocation point) + the hint teaching the single reply path.
160
+ const capabilityEnv = await channelCapabilityEnv(ctx);
161
+
157
162
  return [
158
163
  {
159
164
  type: 'emitEvent',
@@ -178,6 +183,7 @@ export async function effectsForChannelDispatch(
178
183
  externalMessageId,
179
184
  senderName,
180
185
  text,
186
+ cliCapable: capabilityEnv !== null,
181
187
  }),
182
188
  conversationId,
183
189
  channelMessageId: sourceId,
@@ -185,19 +191,19 @@ export async function effectsForChannelDispatch(
185
191
  externalConversationId,
186
192
  externalMessageId,
187
193
  },
188
- // No chatId on purpose: the reply goes out through the provider clip
189
- // (agent-invoked), NOT deliver() — deliver only runs the redundant
190
- // session idle for channel routes (see delivery.ts).
194
+ // No chatId on purpose: the reply is the agent's own lark-cli send,
195
+ // NOT deliver() — deliver only runs the redundant session idle for
196
+ // channel routes (see delivery.ts).
191
197
  replyRoute: {
192
198
  kind: 'provider_http',
193
199
  connectionId: ctx.connectionId,
194
200
  data: { conversationId, envelopeId },
195
201
  },
196
- // No invocation context: PRLL_CHAT_ID must not point CLI defaults at
197
- // a chat that doesn't exist, and the reply targets (external ids)
198
- // already ride the prompt verbatim. (So no PRLL_DISPATCH_SOURCE_*
199
- // either — the external reply goes out through the provider clip,
200
- // and this row's ledger close is the turn event's complete-sources.)
202
+ // Invocation context carries ONLY the capability env: no PRLL_CHAT_ID
203
+ // (it must not point CLI defaults at a chat that doesn't exist) and
204
+ // no PRLL_DISPATCH_SOURCE_* (the external reply is a vendor-CLI send,
205
+ // and this row's ledger close is the turn event's complete-sources).
206
+ ...(capabilityEnv ? { context: capabilityEnv } : {}),
201
207
  },
202
208
  },
203
209
  // No ack effect — see the message path (inbound.ts): the row stays
package/src/inbound.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  ConnectorEffect,
6
6
  WebSocketFrame,
7
7
  } from '@parel/plugin-sdk';
8
+ import { channelCapabilityEnv } from './channel-capability.js';
8
9
  import { buildChatPrompt, type PromptAttachment } from './channel-prompt.js';
9
10
  import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
10
11
  import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
@@ -25,16 +26,11 @@ import {
25
26
  settleChildEvent,
26
27
  trackFollowUpAck,
27
28
  } from './fork.js';
28
- import {
29
- type AgentEventWithChild,
30
- asChildSpawnFailed,
31
- asConnectorEffects,
32
- type ExtendedConnectorEffect,
33
- } from './parel-sdk-compat.js';
34
29
  import {
35
30
  ackByIdEffect,
36
- ackEffect,
31
+ claimDispatchSource,
37
32
  completeFencedEnvelopes,
33
+ completeSourceEffect,
38
34
  conversationKey,
39
35
  createErrorStep,
40
36
  fetchMainSessionFresh,
@@ -46,7 +42,6 @@ import {
46
42
  ensureSession,
47
43
  invalidateChildSession,
48
44
  invalidateSession,
49
- markDispatchReceived,
50
45
  nextEnvelopeId,
51
46
  patchSession,
52
47
  readActiveEmit,
@@ -400,7 +395,7 @@ async function effectsForDispatch(
400
395
  if (sourceType !== 'message' || !sourceId) return [];
401
396
  if (!chatId) {
402
397
  // A message dispatch with no chat can never be routed or replied to.
403
- return [ackEffect(ctx, sourceType, sourceId)];
398
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
404
399
  }
405
400
  if (await shouldSkipLiveRow(ctx, sourceType, sourceId)) return [];
406
401
 
@@ -416,7 +411,7 @@ async function effectsForDispatch(
416
411
  if (!m.ok) {
417
412
  if (m.status === 404 || m.status === 403) {
418
413
  // Deleted message / lost chat access — permanently unusable, drop it.
419
- return [ackEffect(ctx, sourceType, sourceId)];
414
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
420
415
  }
421
416
  // Transient failure: keep pending, the catch-up sweep retries.
422
417
  return [];
@@ -449,7 +444,7 @@ async function effectsForDispatch(
449
444
  // Truly empty messages never grow content — ack-and-drop rather than let
450
445
  // them pile up in the pending queue forever. (Attachment-only messages
451
446
  // used to take this path; they now ride the attachment lines instead.)
452
- return [ackEffect(ctx, sourceType, sourceId)];
447
+ return [completeSourceEffect(ctx, sourceType, sourceId)];
453
448
  }
454
449
 
455
450
  // Frame the message with its chat of origin: all chats share the agent's
@@ -496,12 +491,18 @@ async function effectsForDispatch(
496
491
  // so the server resolves this WorkItem in the reply's own transaction.
497
492
  // (Fork turns get it too — their rows are legacy-acked so the resolve is a
498
493
  // no-op today, and it wires up for free when fork ledger parity lands.)
494
+ // Channel-capability env (lark-cli auth) rides EVERY turn's invocation
495
+ // context while granted — Feishu workspace ops (docs/calendar/…) are asked
496
+ // for from ordinary Parall chats, not only from channel turns. Null when
497
+ // ungranted or the mint degrades; never blocks the emit.
498
+ const capabilityEnv = await channelCapabilityEnv(ctx);
499
499
  const invocationContext: Record<string, string> = {
500
500
  PRLL_CHAT_ID: chatId,
501
501
  PRLL_TRIGGER_MESSAGE_ID: sourceId,
502
502
  PRLL_DISPATCH_SOURCE_TYPE: sourceType,
503
503
  PRLL_DISPATCH_SOURCE_ID: sourceId,
504
504
  ...(threadRootId ? { PRLL_THREAD_ROOT_ID: threadRootId } : {}),
505
+ ...(capabilityEnv ?? {}),
505
506
  };
506
507
 
507
508
  const envelope = {
@@ -536,7 +537,7 @@ async function effectsForDispatch(
536
537
  // Progress reporting lands on the CHILD's ase_ — its turn does the work,
537
538
  // and the replyRoute carries childRef so deliver() links the reply to the
538
539
  // child session (and idles IT, not main).
539
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
540
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
540
541
  const childAse = await ensureChildSession(ctx, decision.childRef);
541
542
  if (childAse) {
542
543
  const results = await Promise.all([
@@ -586,18 +587,18 @@ async function effectsForDispatch(
586
587
  source_id: sourceId,
587
588
  chat_id: chatId,
588
589
  });
589
- return asConnectorEffects([
590
- { type: 'setTimer', key: retryKey, at: ctx.now() + FORK_RETRY_DELAY_MS },
591
- ]);
590
+ return [{ type: 'setTimer', key: retryKey, at: ctx.now() + FORK_RETRY_DELAY_MS }];
592
591
  }
593
- return asConnectorEffects([
592
+ return [
594
593
  { type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
595
- ]);
594
+ ];
596
595
  }
597
- return asConnectorEffects([
596
+ return [
598
597
  { type: 'emitEvent', event: childEnvelope, deliverTo: { childRef: decision.childRef } },
599
- ackEffect(ctx, sourceType, sourceId),
600
- ]);
598
+ // Fork-handled rows keep the legacy ack machinery (ledger parity is a
599
+ // declared follow-up); by id — the precise form.
600
+ dispatchId ? ackByIdEffect(ctx, dispatchId) : completeSourceEffect(ctx, sourceType, sourceId),
601
+ ];
601
602
  }
602
603
 
603
604
  if (decision.mode === 'spawn') {
@@ -625,7 +626,7 @@ async function effectsForDispatch(
625
626
  // would leave the message stranded with no timer and no bookkeeping
626
627
  // until a reconnect).
627
628
  let spawnBlackHoled = false;
628
- const timerEffects: ExtendedConnectorEffect[] = [];
629
+ const timerEffects: ConnectorEffect[] = [];
629
630
  if (dispatchId) {
630
631
  const retryKey = `${FORK_RETRY_PREFIX}${dispatchId}`;
631
632
  const attempts = await bumpForkRetryAttempts(ctx, retryKey, {
@@ -643,7 +644,7 @@ async function effectsForDispatch(
643
644
  }
644
645
  }
645
646
  if (!spawnBlackHoled) {
646
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
647
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
647
648
  const childAse = await ensureChildSession(ctx, decision.childRef);
648
649
  if (childAse) {
649
650
  const results = await Promise.all([
@@ -659,7 +660,14 @@ async function effectsForDispatch(
659
660
  }
660
661
  await recordSpawn(ctx, subject, decision.childRef, { dispatchId, sourceId });
661
662
  await receivedSettled;
662
- return asConnectorEffects([
663
+ // Known gap: SpawnChildSessionEffect (#140) carries no invocation
664
+ // context, so the fork's OPENING turn runs without the capability env
665
+ // (lark-cli workspace ops would need a follow-up turn — those arrive
666
+ // as deliverTo envelopes, which inherit the full context). Channel
667
+ // replies are unaffected: channel_message dispatches always ride main.
668
+ // Fix belongs parel-side (context on spawnChildSession) — tracked as a
669
+ // parel ask.
670
+ return [
663
671
  {
664
672
  type: 'spawnChildSession',
665
673
  childRef: decision.childRef,
@@ -667,7 +675,7 @@ async function effectsForDispatch(
667
675
  subject,
668
676
  },
669
677
  ...timerEffects,
670
- ]);
678
+ ];
671
679
  }
672
680
  // fall through: black-holed spawn rides main below.
673
681
  }
@@ -682,7 +690,7 @@ async function effectsForDispatch(
682
690
  // pending→received transition. It is deliberately NOT gated on the
683
691
  // session resolving: a transient session failure must not also cost the
684
692
  // indicator.
685
- const receivedSettled = markDispatchReceived(ctx, sourceType, sourceId);
693
+ const receivedSettled = claimDispatchSource(ctx, sourceType, sourceId);
686
694
  let sessionId = await ensureSession(ctx);
687
695
  if (sessionId) {
688
696
  // The guard marker goes down BEFORE any session write: a stale
@@ -751,7 +759,7 @@ async function effectsForTypedDispatch(
751
759
  data: DispatchData,
752
760
  ctx: ConnectorContext,
753
761
  ): Promise<ConnectorEffect[]> {
754
- // No markDispatchReceived here ON PURPOSE: typed dispatches have no
762
+ // No claimDispatchSource here ON PURPOSE: typed dispatches have no
755
763
  // message-level activity indicator (DispatchResolvedData: "typed
756
764
  // dispatches have no indicator"), so there is no pending ring to light —
757
765
  // the typed input step below is the session panel's timeline entry.
@@ -761,11 +769,21 @@ async function effectsForTypedDispatch(
761
769
  // Typed WorkItems ack by id, never by source: sibling rows (a task
762
770
  // mutation's task_assign + task_update) share (source_type, source_id),
763
771
  // and the source ack would terminally resolve work that was never emitted.
764
- const ack = () =>
765
- dispatchId ? ackByIdEffect(ctx, dispatchId) : ackEffect(ctx, sourceType, sourceId);
772
+ const ack = (): ConnectorEffect[] => {
773
+ if (!dispatchId) {
774
+ // No WorkItem id (malformed payload): the by-source form would
775
+ // terminally resolve SIBLING typed rows sharing this source — leave
776
+ // the row pending for the sweep instead.
777
+ console.error(
778
+ `[parel-channel] typed dispatch without id (${sourceType}:${sourceId}) left pending`,
779
+ );
780
+ return [];
781
+ }
782
+ return [ackByIdEffect(ctx, dispatchId)];
783
+ };
766
784
  const plan = await planTypedDispatch(data, ctx);
767
785
  if (plan.outcome === 'pending') return [];
768
- if (plan.outcome === 'stale') return [ack()];
786
+ if (plan.outcome === 'stale') return ack();
769
787
 
770
788
  const envelopeId = dispatchId || `${sourceType}:${sourceId}`;
771
789
  let sessionId = await ensureSession(ctx);
@@ -796,6 +814,16 @@ async function effectsForTypedDispatch(
796
814
  }
797
815
  }
798
816
 
817
+ // Typed turns carry the capability env too: a task or schedule is exactly
818
+ // where "create the Feishu doc / check the calendar" work is assigned, so
819
+ // the every-turn capability contract covers them (null when ungranted or
820
+ // the mint degrades; never blocks the emit).
821
+ const capabilityEnv = await channelCapabilityEnv(ctx);
822
+ const typedContext: Record<string, string> = {
823
+ ...(plan.chatId ? { PRLL_CHAT_ID: plan.chatId, PRLL_TRIGGER_MESSAGE_ID: sourceId } : {}),
824
+ ...(capabilityEnv ?? {}),
825
+ };
826
+
799
827
  return [
800
828
  {
801
829
  type: 'emitEvent',
@@ -822,17 +850,10 @@ async function effectsForTypedDispatch(
822
850
  ? { chatId: plan.chatId, messageId: sourceId, envelopeId }
823
851
  : { envelopeId },
824
852
  },
825
- ...(plan.chatId
826
- ? {
827
- context: {
828
- PRLL_CHAT_ID: plan.chatId,
829
- PRLL_TRIGGER_MESSAGE_ID: sourceId,
830
- },
831
- }
832
- : {}),
853
+ ...(Object.keys(typedContext).length > 0 ? { context: typedContext } : {}),
833
854
  },
834
855
  },
835
- ack(),
856
+ ...ack(),
836
857
  ];
837
858
  }
838
859
 
@@ -932,21 +953,20 @@ async function handleParallAgentEventInner(
932
953
  event: AgentEvent,
933
954
  ctx: ConnectorContext,
934
955
  ): Promise<AgentEventEffect[]> {
935
- // spawnChildSession's asynchronous error channel — not turn-scoped, pushed
936
- // regardless of observe scopes, and shaped outside the published AgentEvent
937
- // union (see parel-sdk-compat). Handle it before anything subject-gated.
938
- const spawnFailed = asChildSpawnFailed(event);
939
- if (spawnFailed) {
956
+ // spawnChildSession's asynchronous error channel — not turn-scoped and
957
+ // pushed regardless of observe scopes. Handle it before anything
958
+ // subject-gated.
959
+ if (event.type === 'child_spawn_failed') {
940
960
  const effects: AgentEventEffect[] = [];
941
961
  try {
942
- const outcome = await handleChildSpawnFailed(ctx, spawnFailed);
962
+ const outcome = await handleChildSpawnFailed(ctx, event);
943
963
  if (outcome.cleared) {
944
964
  // The spawn's ase_ was already created active with an input step; no
945
965
  // child turn will ever emit a terminal event for it — settle it here
946
966
  // or the session (and its processing indicator) stays active forever.
947
- const orphanAse = await ensureChildSession(ctx, spawnFailed.childRef);
967
+ const orphanAse = await ensureChildSession(ctx, event.childRef);
948
968
  if (orphanAse) await patchSession(ctx, orphanAse, { status: 'idle' });
949
- await invalidateChildSession(ctx, spawnFailed.childRef);
969
+ await invalidateChildSession(ctx, event.childRef);
950
970
  }
951
971
  // Re-drive the stranded messages WITHOUT waiting for a reconnect sweep
952
972
  // (a healthy long-lived socket never re-runs onOpen): park their
@@ -971,15 +991,14 @@ async function handleParallAgentEventInner(
971
991
  }
972
992
  return effects;
973
993
  }
974
- const withChild = event as AgentEventWithChild;
975
- if (withChild.childRef) {
976
- return handleChildAgentEvent(withChild, ctx);
994
+ if (event.childRef) {
995
+ return handleChildAgentEvent(event, ctx);
977
996
  }
978
997
  const chatId = event.subject;
979
998
  try {
980
999
  // Best-effort busy classifier for fork-on-busy: only MAIN turns feed it
981
1000
  // (child events took the branch above).
982
- await noteMainTurnEvent(ctx, withChild).catch(() => {});
1001
+ await noteMainTurnEvent(ctx, event).catch(() => {});
983
1002
  if (!chatId) return [];
984
1003
  if (event.type === 'turn_completed' || event.type === 'turn_failed') {
985
1004
  // Ledger close FIRST, fenced per source — deliberately NOT behind the
@@ -995,7 +1014,11 @@ async function handleParallAgentEventInner(
995
1014
  // the absorbing turn's envelopeIds and settle here naturally. The
996
1015
  // server decides reply broad-cover vs no_action per batch; turn_failed
997
1016
  // sweeps the same way with the error step below as the visible record.
998
- await completeFencedEnvelopes(ctx, event.envelopeIds);
1017
+ await completeFencedEnvelopes(
1018
+ ctx,
1019
+ event.envelopeIds,
1020
+ event.type === 'turn_failed' ? 'error' : 'ok',
1021
+ );
999
1022
 
1000
1023
  // Staleness guard: only idle if this event's turn covers the AGENT'S
1001
1024
  // latest emitted envelope (the marker is agent-global — all chats share
@@ -1065,7 +1088,7 @@ async function handleParallAgentEventInner(
1065
1088
  * turnEnvelope marker or the busy classifier.
1066
1089
  */
1067
1090
  async function handleChildAgentEvent(
1068
- event: AgentEventWithChild,
1091
+ event: AgentEvent,
1069
1092
  ctx: ConnectorContext,
1070
1093
  ): Promise<AgentEventEffect[]> {
1071
1094
  const childRef = event.childRef ?? '';