@parall/parel-channel 1.56.2 → 1.57.2

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.
@@ -36,8 +36,12 @@ export interface ChatPromptArgs {
36
36
  chatId: string;
37
37
  /** Display name of the chat; undefined when unresolved (best-effort). */
38
38
  chatName?: string;
39
- /** Sender user id (usr_); display-name enrichment is a follow-up. */
39
+ /** Sender user id (usr_). */
40
40
  senderId?: string;
41
+ /** Sender display name; rendered before the id when resolved (best-effort). */
42
+ senderName?: string;
43
+ /** Chat shape (direct/group); the type segment is omitted when unresolved. */
44
+ chatType?: string;
41
45
  /**
42
46
  * The inbound message id (msg_); anchors the forwarded-message hint's
43
47
  * explicit --from so coalesced turns don't lean on the single trigger id.
@@ -70,6 +74,7 @@ export interface ChannelPromptArgs {
70
74
  /** Conversation shape as recorded on the ChannelConversation (dm/group). */
71
75
  conversationType?: string;
72
76
  externalConversationId?: string;
77
+ externalConversationName?: string;
73
78
  externalMessageId?: string;
74
79
  senderName: string;
75
80
  /** The inbound message text (raw, un-framed). */
@@ -1 +1 @@
1
- {"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA+BH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,WAAW,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACjC,8FAA8F;IAC9F,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAmBD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAqB5D;AAED,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAkBlE"}
1
+ {"version":3,"file":"channel-prompt.d.ts","sourceRoot":"","sources":["../src/channel-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA+BH,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6BAA6B;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,WAAW,CAAC,EAAE,gBAAgB,EAAE,CAAC;IACjC,8FAA8F;IAC9F,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAmBD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAwC5D;AAgBD,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CA2BlE"}
@@ -73,16 +73,31 @@ function attachmentLines(attachments) {
73
73
  * not re-created here.
74
74
  */
75
75
  export function buildChatPrompt(args) {
76
- const chatLabel = args.chatName
77
- ? `${sanitizeMeta(args.chatId)} ${sanitizeMeta(args.chatName)}`
78
- : sanitizeMeta(args.chatId);
79
- const lines = [`[Chat: ${chatLabel}]`];
80
- if (args.senderId)
81
- lines.push(`[From: ${sanitizeMeta(args.senderId)}]`);
76
+ // agent-core's event-format.ts message branch is the SSOT for this frame:
77
+ // same lines, same order, same send-hint. The only sanctioned deltas are
78
+ // absent-field fallbacks (no chat name → bare URI, no sender name → bare
79
+ // id, no chat type → segment omitted) — agent-core's host always has them.
80
+ const lines = [`[Event: message.new]`];
81
+ const chatUri = `prll://${args.chatId}`;
82
+ const chatLabel = args.chatName ? `"${sanitizeMeta(args.chatName)}" (${chatUri})` : chatUri;
83
+ const typeSeg = args.chatType ? ` | type: ${sanitizeMeta(args.chatType)}` : '';
84
+ lines.push(`[Chat: ${chatLabel}${typeSeg}]`);
85
+ if (args.senderId) {
86
+ lines.push(args.senderName
87
+ ? `[From: ${sanitizeMeta(args.senderName)} (prll://${args.senderId})]`
88
+ : `[From: prll://${args.senderId}]`);
89
+ }
90
+ if (args.messageId)
91
+ lines.push(`[Message ID: prll://${args.messageId}]`);
82
92
  if (args.threadRootId)
83
- lines.push(`[Thread: ${sanitizeMeta(args.threadRootId)}]`);
93
+ lines.push(`[Thread: prll://${sanitizeMeta(args.threadRootId)}]`);
84
94
  if (args.deliveryReason)
85
95
  lines.push(`[Delivery: ${sanitizeMeta(args.deliveryReason)}]`);
96
+ if (args.deliveryReason === 'watcher' && args.threadRootId) {
97
+ // agent-core's threadWatcherHint, verbatim — the cooperative nudge under
98
+ // the server's agent-loop ceiling (agent-dm-loop-prevention.md Layer 4).
99
+ lines.push(`[Hint: watcher — you were not addressed in this reply. Reply only if you add something new; otherwise run \`parall no-reply\`. If this thread is not relevant to you, or you have nothing more to contribute, run \`parall messages unwatch prll://${sanitizeMeta(args.threadRootId)}\` so later replies stop waking you.]`);
100
+ }
86
101
  if (args.noReply)
87
102
  lines.push(`[Hint: no_reply]`);
88
103
  if (isForwardedMessageBody(args.text)) {
@@ -94,13 +109,36 @@ export function buildChatPrompt(args) {
94
109
  }
95
110
  lines.push(...attachmentLines(args.attachments));
96
111
  lines.push('', args.text);
97
- return lines.join('\n');
112
+ return lines.join('\n') + chatSendHint(args);
113
+ }
114
+ /**
115
+ * agent-core's buildSendMessageHint chat branch, verbatim: every chat event
116
+ * ends with the concrete send command. The standing system prompt teaches the
117
+ * CLI-only reply contract too, but a per-event reminder is what the standard
118
+ * runtimes ship — and weak models demonstrably drop the send step without it.
119
+ */
120
+ function chatSendHint(args) {
121
+ if (args.noReply)
122
+ return '';
123
+ if (args.threadRootId) {
124
+ return `\n<system-reminder>To reply in this thread, run \`parall messages send prll://${args.chatId} --thread-root-id ${sanitizeMeta(args.threadRootId)} --text-file - <<'EOF'\` … \`EOF\` — the quoted heredoc keeps \`$\`, backticks and apostrophes literal (plain \`--text "$1,000"\` sends \`,000\`). Your plain text output is not delivered to the chat.</system-reminder>`;
125
+ }
126
+ return `\n<system-reminder>To reply, run \`parall messages send prll://${args.chatId} --text-file - <<'EOF'\` … \`EOF\` — the quoted heredoc keeps \`$\`, backticks and apostrophes literal (plain \`--text "$1,000"\` sends \`,000\`). Your plain text output is not delivered to the chat.</system-reminder>`;
98
127
  }
99
128
  export function buildChannelPrompt(args) {
100
129
  const providerLabel = sanitizeMeta(args.provider ?? 'external IM');
101
- const convLabel = args.externalConversationId
102
- ? `${sanitizeMeta(args.externalConversationId)} (${sanitizeMeta(args.conversationType ?? 'conversation')})`
103
- : sanitizeMeta(args.conversationType ?? 'conversation');
130
+ const convType = sanitizeMeta(args.conversationType ?? 'conversation');
131
+ const convId = args.externalConversationId
132
+ ? sanitizeMeta(args.externalConversationId)
133
+ : undefined;
134
+ const convName = args.externalConversationName
135
+ ? sanitizeMeta(args.externalConversationName)
136
+ : undefined;
137
+ const convLabel = convName
138
+ ? `${convName} (${convId ? `${convId}, ` : ''}${convType})`
139
+ : convId
140
+ ? `${convId} (${convType})`
141
+ : convType;
104
142
  const lines = [
105
143
  '[Event: channel.message]',
106
144
  `[Channel: ${providerLabel} | conversation: ${convLabel}]`,
@@ -114,14 +152,23 @@ export function buildChannelPrompt(args) {
114
152
  return lines.join('\n') + buildChannelReplyHint(args);
115
153
  }
116
154
  function buildChannelReplyHint(args) {
117
- // Single-path routing (multi-channel-architecture-design §6/§8.3): with
118
- // the feishu-cli grant, the minted LARKSUITE_CLI_* env rides this turn's
119
- // invocation context, so the official lark-cli works ambiently in the
120
- // sandbox and is THE reply path. Without the grant there is no outbound
121
- // path — say so instead of pointing at the retired provider clip.
122
- // cliCapable alone decides: the mint is feishu-specific, so a successful
123
- // grant implies Feishu even when the cosmetic provider-label lookup failed.
124
- if (args.cliCapable) {
155
+ // The outbound path is a property of the PROVIDER, not of the agent's
156
+ // grants. Feishu replies go through the vendor CLI with a platform-brokered
157
+ // credential; Slack and WeChat replies are platform verbs the `parall` CLI
158
+ // already carries under the agent's own identity nothing to mint.
159
+ //
160
+ // Deciding on the Feishu grant alone told a Slack or WeChat inbound that
161
+ // outbound was disabled while its own system prompt declared the matching
162
+ // `parall <provider> send` verb, and could hand a dual-bound agent a
163
+ // `lark-cli` instruction addressed to a Slack conversation.
164
+ //
165
+ // Whether the command is ALLOWED stays a runtime question: every verb and
166
+ // the Feishu broker re-check the grant at call time. This only names the
167
+ // right door.
168
+ // Both halves are required: the mint is Feishu-only and says nothing about
169
+ // where the message arrived, so a dual-bound agent's Slack inbound must not
170
+ // fall into this branch just because its Feishu grant is live.
171
+ if (args.cliCapable && args.provider === 'feishu') {
125
172
  const convRef = args.externalConversationId
126
173
  ? `chat_id "${args.externalConversationId}"`
127
174
  : 'the conversation id named in this event';
@@ -130,6 +177,24 @@ function buildChannelReplyHint(args) {
130
177
  : '';
131
178
  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>`;
132
179
  }
180
+ if (args.provider === 'slack') {
181
+ // The flag and its value together, as agent-core renders it: writing the
182
+ // option separately from a labelled reference produced
183
+ // `--channel channel "C123"`.
184
+ const channelArg = args.externalConversationId
185
+ ? ` --channel "${args.externalConversationId}"`
186
+ : ' --channel <conversation id from this event>';
187
+ // Channel sends REQUIRE a reply anchor; DMs take one too so a question
188
+ // asked in a thread is answered in that thread.
189
+ const replyTo = args.externalMessageId ? ` --reply-to "${args.externalMessageId}"` : '';
190
+ return `\n<system-reminder>To reply, use \`parall slack send${channelArg}${replyTo} --text <text>\` from your sandbox (use \`--text-file -\` with a quoted heredoc for anything containing \`$\`, backticks or quotes). That verb is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
191
+ }
192
+ if (args.provider === 'wechat') {
193
+ const toArg = args.externalConversationId
194
+ ? `"${args.externalConversationId}"`
195
+ : '<wxid from this event>';
196
+ return `\n<system-reminder>To reply, use \`parall wechat send --to ${toArg} --text <text>\` from your sandbox (use \`--text-file -\` with a quoted heredoc for anything containing \`$\`, backticks or quotes). WeChat has no threads: a group reply lands in the whole room. That verb is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
197
+ }
133
198
  const platform = args.provider ?? 'the external platform';
134
199
  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>`;
135
200
  }
@@ -1 +1 @@
1
- {"version":3,"file":"inbound-channel.d.ts","sourceRoot":"","sources":["../src/inbound-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAkB3E;;;;GAIG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAwL5B"}
1
+ {"version":3,"file":"inbound-channel.d.ts","sourceRoot":"","sources":["../src/inbound-channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAkB3E;;;;GAIG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,OAAQ,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,gBAAgB,GACpB,OAAO,CAAC,eAAe,EAAE,CAAC,CAmM5B"}
@@ -1,7 +1,7 @@
1
1
  import { channelCapabilityEnv } from './channel-capability.js';
2
2
  import { buildChannelPrompt } from './channel-prompt.js';
3
3
  import { parallApiUrl, parallOrgId, requireAgk } from './connect.js';
4
- import { claimDispatchSource, completeSourceEffect, createChannelInputStep, ensureSession, invalidateSession, nextEnvelopeId, patchSession, readActiveEmit, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
4
+ import { claimDispatchSource, completeSourceEffect, createChannelInputStep, ensureSession, invalidateSession, nextEnvelopeId, patchSession, REQUEST_TIMEOUT_MS, readActiveEmit, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
5
5
  /**
6
6
  * External IM inbound (channel_message dispatches — Feishu/Slack via the
7
7
  * platform-mediated channel, external-im-channel-design.md). Split from
@@ -70,13 +70,18 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
70
70
  }
71
71
  const conv = (await c.json().catch(() => null));
72
72
  // Provider (= clip alias) for prompt labeling + the reply hint.
73
- // Best-effort: an unresolved provider degrades the wording, never blocks.
73
+ // A gone connection degrades the wording; an unreachable platform throws,
74
+ // because the provider now decides the turn's outbound path (see the
75
+ // resolver).
74
76
  const connectionId = conv?.connection_id ? String(conv.connection_id) : undefined;
75
77
  const provider = connectionId ? await resolveChannelProvider(ctx, connectionId, get) : undefined;
76
78
  const senderName = msg?.external_user_name || msg?.external_user_id || 'external user';
77
79
  const externalConversationId = conv?.external_conversation_id
78
80
  ? String(conv.external_conversation_id)
79
81
  : undefined;
82
+ const externalConversationName = conv?.external_conversation_name
83
+ ? String(conv.external_conversation_name)
84
+ : undefined;
80
85
  const externalMessageId = msg?.external_message_id ? String(msg.external_message_id) : undefined;
81
86
  // Progress reporting — identical ordering contract to the message path
82
87
  // (see the comments there): received settles before this function returns;
@@ -141,7 +146,10 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
141
146
  await writeActiveEmit(ctx, 'channel_message', sourceId, envelopeId, sessionId ?? undefined);
142
147
  // Live capability grant → this turn's lark-cli auth env (mint gate = the
143
148
  // no-drift revocation point) + the hint teaching the single reply path.
144
- const capabilityEnv = await channelCapabilityEnv(ctx);
149
+ // The mint is Feishu-only. Asking for it on a Slack or WeChat inbound buys
150
+ // nothing (those replies are platform verbs under the agent's own identity)
151
+ // and would put a Feishu credential in a turn that has no use for one.
152
+ const capabilityEnv = provider === 'feishu' ? await channelCapabilityEnv(ctx) : null;
145
153
  return [
146
154
  {
147
155
  type: 'emitEvent',
@@ -163,6 +171,7 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
163
171
  provider,
164
172
  conversationType: conv?.conversation_type ? String(conv.conversation_type) : undefined,
165
173
  externalConversationId,
174
+ externalConversationName,
166
175
  externalMessageId,
167
176
  senderName,
168
177
  text,
@@ -172,6 +181,7 @@ export async function effectsForChannelDispatch(sourceId, ctx) {
172
181
  channelMessageId: sourceId,
173
182
  provider,
174
183
  externalConversationId,
184
+ externalConversationName,
175
185
  externalMessageId,
176
186
  },
177
187
  // No chatId on purpose: the reply is the agent's own lark-cli send,
@@ -225,17 +235,20 @@ async function resolveChannelProvider(ctx, connectionId, get) {
225
235
  const cached = await ctx.store?.get(cacheKey).catch(() => null);
226
236
  if (typeof cached === 'string' && cached)
227
237
  return cached;
228
- try {
229
- const res = await get(`/channel-connections/${connectionId}`, PROVIDER_LOOKUP_TIMEOUT_MS);
230
- if (!res.ok)
231
- return undefined;
232
- const body = (await res.json().catch(() => null));
233
- const provider = body?.provider ? String(body.provider) : undefined;
234
- if (provider)
235
- await ctx.store?.set(cacheKey, provider).catch(() => { });
236
- return provider;
237
- }
238
- catch {
238
+ const res = await get(`/channel-connections/${connectionId}`, PROVIDER_LOOKUP_TIMEOUT_MS);
239
+ // A gone connection is an ANSWER (no provider); anything else is the
240
+ // platform failing to answer. That distinction used to be moot — the
241
+ // provider was prompt wording — but it now decides which outbound path the
242
+ // turn is taught, and a turn taught nothing still completes its source and
243
+ // is never revisited. Throwing leaves the dispatch for the catch-up pass.
244
+ if (res.status === 404 || res.status === 410)
239
245
  return undefined;
246
+ if (!res.ok) {
247
+ throw new Error(`channel connection lookup failed (${res.status})`);
240
248
  }
249
+ const body = (await res.json().catch(() => null));
250
+ const provider = body?.provider ? String(body.provider) : undefined;
251
+ if (provider)
252
+ await ctx.store?.set(cacheKey, provider).catch(() => { });
253
+ return provider;
241
254
  }
package/dist/inbound.js CHANGED
@@ -1,10 +1,10 @@
1
+ import { armCatchUpEffect, beginCatchUp, CATCHUP_TIMER_KEY, deferToCatchUp, runCatchUpRound, } from './catchup.js';
1
2
  import { channelCapabilityEnv } from './channel-capability.js';
2
3
  import { buildChatPrompt } from './channel-prompt.js';
3
- import { armCatchUpEffect, beginCatchUp, CATCHUP_TIMER_KEY, deferToCatchUp, runCatchUpRound, } from './catchup.js';
4
4
  import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
5
- import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
6
5
  import { buildForkScopePrefix, bumpForkRetryAttempts, childConfirmed, childRefKnown, clearForkState, clearForkSubject, FORK_RETRY_DELAY_MS, FORK_RETRY_PREFIX, handleChildSpawnFailed, MAX_SPAWN_VERIFY_ATTEMPTS, noteMainTurnEvent, planForkDecision, recordSpawn, settleChildEvent, trackFollowUpAck, } from './fork.js';
7
- import { ackByIdEffect, claimDispatchSource, completeFencedEnvelopes, completeSourceEffect, conversationKey, createErrorStep, createInputStep, createTraceStep, createTypedInputStep, EMIT_TTL_MS, ensureChildSession, ensureSession, invalidateChildSession, invalidateSession, nextEnvelopeId, noteTurnEventsObserved, patchSession, readActiveEmit, reportingEnabled, REQUEST_TIMEOUT_MS, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
6
+ import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
7
+ import { ackByIdEffect, claimDispatchSource, completeFencedEnvelopes, completeSourceEffect, conversationKey, createErrorStep, createInputStep, createTraceStep, createTypedInputStep, EMIT_TTL_MS, ensureChildSession, ensureSession, invalidateChildSession, invalidateSession, nextEnvelopeId, noteTurnEventsObserved, patchSession, REQUEST_TIMEOUT_MS, readActiveEmit, reportingEnabled, TURN_ENVELOPE_KEY, writeActiveEmit, } from './session.js';
8
8
  import { isTypedEventType, planTypedDispatch } from './typed-dispatch.js';
9
9
  /**
10
10
  * Connection-keyed hook serialization — a v1-host compatibility shim. The v1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/parel-channel",
3
- "version": "1.56.2",
3
+ "version": "1.57.2",
4
4
  "description": "Parall channel plugin for the parel runtime — lets a parel agent treat Parall as a managed_ws channel (receive dispatches, reply via the Parall API)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -67,8 +67,12 @@ export interface ChatPromptArgs {
67
67
  chatId: string;
68
68
  /** Display name of the chat; undefined when unresolved (best-effort). */
69
69
  chatName?: string;
70
- /** Sender user id (usr_); display-name enrichment is a follow-up. */
70
+ /** Sender user id (usr_). */
71
71
  senderId?: string;
72
+ /** Sender display name; rendered before the id when resolved (best-effort). */
73
+ senderName?: string;
74
+ /** Chat shape (direct/group); the type segment is omitted when unresolved. */
75
+ chatType?: string;
72
76
  /**
73
77
  * The inbound message id (msg_); anchors the forwarded-message hint's
74
78
  * explicit --from so coalesced turns don't lean on the single trigger id.
@@ -113,13 +117,32 @@ function attachmentLines(attachments: PromptAttachment[] | undefined): string[]
113
117
  * not re-created here.
114
118
  */
115
119
  export function buildChatPrompt(args: ChatPromptArgs): string {
116
- const chatLabel = args.chatName
117
- ? `${sanitizeMeta(args.chatId)} ${sanitizeMeta(args.chatName)}`
118
- : sanitizeMeta(args.chatId);
119
- const lines = [`[Chat: ${chatLabel}]`];
120
- if (args.senderId) lines.push(`[From: ${sanitizeMeta(args.senderId)}]`);
121
- if (args.threadRootId) lines.push(`[Thread: ${sanitizeMeta(args.threadRootId)}]`);
120
+ // agent-core's event-format.ts message branch is the SSOT for this frame:
121
+ // same lines, same order, same send-hint. The only sanctioned deltas are
122
+ // absent-field fallbacks (no chat name → bare URI, no sender name → bare
123
+ // id, no chat type → segment omitted) — agent-core's host always has them.
124
+ const lines = [`[Event: message.new]`];
125
+ const chatUri = `prll://${args.chatId}`;
126
+ const chatLabel = args.chatName ? `"${sanitizeMeta(args.chatName)}" (${chatUri})` : chatUri;
127
+ const typeSeg = args.chatType ? ` | type: ${sanitizeMeta(args.chatType)}` : '';
128
+ lines.push(`[Chat: ${chatLabel}${typeSeg}]`);
129
+ if (args.senderId) {
130
+ lines.push(
131
+ args.senderName
132
+ ? `[From: ${sanitizeMeta(args.senderName)} (prll://${args.senderId})]`
133
+ : `[From: prll://${args.senderId}]`,
134
+ );
135
+ }
136
+ if (args.messageId) lines.push(`[Message ID: prll://${args.messageId}]`);
137
+ if (args.threadRootId) lines.push(`[Thread: prll://${sanitizeMeta(args.threadRootId)}]`);
122
138
  if (args.deliveryReason) lines.push(`[Delivery: ${sanitizeMeta(args.deliveryReason)}]`);
139
+ if (args.deliveryReason === 'watcher' && args.threadRootId) {
140
+ // agent-core's threadWatcherHint, verbatim — the cooperative nudge under
141
+ // the server's agent-loop ceiling (agent-dm-loop-prevention.md Layer 4).
142
+ lines.push(
143
+ `[Hint: watcher — you were not addressed in this reply. Reply only if you add something new; otherwise run \`parall no-reply\`. If this thread is not relevant to you, or you have nothing more to contribute, run \`parall messages unwatch prll://${sanitizeMeta(args.threadRootId)}\` so later replies stop waking you.]`,
144
+ );
145
+ }
123
146
  if (args.noReply) lines.push(`[Hint: no_reply]`);
124
147
  if (isForwardedMessageBody(args.text)) {
125
148
  // Embed the concrete --from anchor: parel snapshots one invocation
@@ -132,7 +155,21 @@ export function buildChatPrompt(args: ChatPromptArgs): string {
132
155
  }
133
156
  lines.push(...attachmentLines(args.attachments));
134
157
  lines.push('', args.text);
135
- return lines.join('\n');
158
+ return lines.join('\n') + chatSendHint(args);
159
+ }
160
+
161
+ /**
162
+ * agent-core's buildSendMessageHint chat branch, verbatim: every chat event
163
+ * ends with the concrete send command. The standing system prompt teaches the
164
+ * CLI-only reply contract too, but a per-event reminder is what the standard
165
+ * runtimes ship — and weak models demonstrably drop the send step without it.
166
+ */
167
+ function chatSendHint(args: ChatPromptArgs): string {
168
+ if (args.noReply) return '';
169
+ if (args.threadRootId) {
170
+ return `\n<system-reminder>To reply in this thread, run \`parall messages send prll://${args.chatId} --thread-root-id ${sanitizeMeta(args.threadRootId)} --text-file - <<'EOF'\` … \`EOF\` — the quoted heredoc keeps \`$\`, backticks and apostrophes literal (plain \`--text "$1,000"\` sends \`,000\`). Your plain text output is not delivered to the chat.</system-reminder>`;
171
+ }
172
+ return `\n<system-reminder>To reply, run \`parall messages send prll://${args.chatId} --text-file - <<'EOF'\` … \`EOF\` — the quoted heredoc keeps \`$\`, backticks and apostrophes literal (plain \`--text "$1,000"\` sends \`,000\`). Your plain text output is not delivered to the chat.</system-reminder>`;
136
173
  }
137
174
 
138
175
  export interface ChannelPromptArgs {
@@ -141,6 +178,7 @@ export interface ChannelPromptArgs {
141
178
  /** Conversation shape as recorded on the ChannelConversation (dm/group). */
142
179
  conversationType?: string;
143
180
  externalConversationId?: string;
181
+ externalConversationName?: string;
144
182
  externalMessageId?: string;
145
183
  senderName: string;
146
184
  /** The inbound message text (raw, un-framed). */
@@ -156,9 +194,18 @@ export interface ChannelPromptArgs {
156
194
 
157
195
  export function buildChannelPrompt(args: ChannelPromptArgs): string {
158
196
  const providerLabel = sanitizeMeta(args.provider ?? 'external IM');
159
- const convLabel = args.externalConversationId
160
- ? `${sanitizeMeta(args.externalConversationId)} (${sanitizeMeta(args.conversationType ?? 'conversation')})`
161
- : sanitizeMeta(args.conversationType ?? 'conversation');
197
+ const convType = sanitizeMeta(args.conversationType ?? 'conversation');
198
+ const convId = args.externalConversationId
199
+ ? sanitizeMeta(args.externalConversationId)
200
+ : undefined;
201
+ const convName = args.externalConversationName
202
+ ? sanitizeMeta(args.externalConversationName)
203
+ : undefined;
204
+ const convLabel = convName
205
+ ? `${convName} (${convId ? `${convId}, ` : ''}${convType})`
206
+ : convId
207
+ ? `${convId} (${convType})`
208
+ : convType;
162
209
  const lines = [
163
210
  '[Event: channel.message]',
164
211
  `[Channel: ${providerLabel} | conversation: ${convLabel}]`,
@@ -175,14 +222,23 @@ export function buildChannelPrompt(args: ChannelPromptArgs): string {
175
222
  }
176
223
 
177
224
  function buildChannelReplyHint(args: ChannelPromptArgs): string {
178
- // Single-path routing (multi-channel-architecture-design §6/§8.3): with
179
- // the feishu-cli grant, the minted LARKSUITE_CLI_* env rides this turn's
180
- // invocation context, so the official lark-cli works ambiently in the
181
- // sandbox and is THE reply path. Without the grant there is no outbound
182
- // path — say so instead of pointing at the retired provider clip.
183
- // cliCapable alone decides: the mint is feishu-specific, so a successful
184
- // grant implies Feishu even when the cosmetic provider-label lookup failed.
185
- if (args.cliCapable) {
225
+ // The outbound path is a property of the PROVIDER, not of the agent's
226
+ // grants. Feishu replies go through the vendor CLI with a platform-brokered
227
+ // credential; Slack and WeChat replies are platform verbs the `parall` CLI
228
+ // already carries under the agent's own identity nothing to mint.
229
+ //
230
+ // Deciding on the Feishu grant alone told a Slack or WeChat inbound that
231
+ // outbound was disabled while its own system prompt declared the matching
232
+ // `parall <provider> send` verb, and could hand a dual-bound agent a
233
+ // `lark-cli` instruction addressed to a Slack conversation.
234
+ //
235
+ // Whether the command is ALLOWED stays a runtime question: every verb and
236
+ // the Feishu broker re-check the grant at call time. This only names the
237
+ // right door.
238
+ // Both halves are required: the mint is Feishu-only and says nothing about
239
+ // where the message arrived, so a dual-bound agent's Slack inbound must not
240
+ // fall into this branch just because its Feishu grant is live.
241
+ if (args.cliCapable && args.provider === 'feishu') {
186
242
  const convRef = args.externalConversationId
187
243
  ? `chat_id "${args.externalConversationId}"`
188
244
  : 'the conversation id named in this event';
@@ -191,6 +247,24 @@ function buildChannelReplyHint(args: ChannelPromptArgs): string {
191
247
  : '';
192
248
  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>`;
193
249
  }
250
+ if (args.provider === 'slack') {
251
+ // The flag and its value together, as agent-core renders it: writing the
252
+ // option separately from a labelled reference produced
253
+ // `--channel channel "C123"`.
254
+ const channelArg = args.externalConversationId
255
+ ? ` --channel "${args.externalConversationId}"`
256
+ : ' --channel <conversation id from this event>';
257
+ // Channel sends REQUIRE a reply anchor; DMs take one too so a question
258
+ // asked in a thread is answered in that thread.
259
+ const replyTo = args.externalMessageId ? ` --reply-to "${args.externalMessageId}"` : '';
260
+ return `\n<system-reminder>To reply, use \`parall slack send${channelArg}${replyTo} --text <text>\` from your sandbox (use \`--text-file -\` with a quoted heredoc for anything containing \`$\`, backticks or quotes). That verb is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
261
+ }
262
+ if (args.provider === 'wechat') {
263
+ const toArg = args.externalConversationId
264
+ ? `"${args.externalConversationId}"`
265
+ : '<wxid from this event>';
266
+ return `\n<system-reminder>To reply, use \`parall wechat send --to ${toArg} --text <text>\` from your sandbox (use \`--text-file -\` with a quoted heredoc for anything containing \`$\`, backticks or quotes). WeChat has no threads: a group reply lands in the whole room. That verb is the ONLY outbound path — your plain text output is NOT delivered to the external conversation.</system-reminder>`;
267
+ }
194
268
  const platform = args.provider ?? 'the external platform';
195
269
  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>`;
196
270
  }
@@ -10,8 +10,8 @@ import {
10
10
  invalidateSession,
11
11
  nextEnvelopeId,
12
12
  patchSession,
13
- readActiveEmit,
14
13
  REQUEST_TIMEOUT_MS,
14
+ readActiveEmit,
15
15
  TURN_ENVELOPE_KEY,
16
16
  writeActiveEmit,
17
17
  } from './session.js';
@@ -100,11 +100,14 @@ export async function effectsForChannelDispatch(
100
100
  const conv = (await c.json().catch(() => null)) as {
101
101
  connection_id?: string;
102
102
  external_conversation_id?: string;
103
+ external_conversation_name?: string;
103
104
  conversation_type?: string;
104
105
  } | null;
105
106
 
106
107
  // Provider (= clip alias) for prompt labeling + the reply hint.
107
- // Best-effort: an unresolved provider degrades the wording, never blocks.
108
+ // A gone connection degrades the wording; an unreachable platform throws,
109
+ // because the provider now decides the turn's outbound path (see the
110
+ // resolver).
108
111
  const connectionId = conv?.connection_id ? String(conv.connection_id) : undefined;
109
112
  const provider = connectionId ? await resolveChannelProvider(ctx, connectionId, get) : undefined;
110
113
 
@@ -112,6 +115,9 @@ export async function effectsForChannelDispatch(
112
115
  const externalConversationId = conv?.external_conversation_id
113
116
  ? String(conv.external_conversation_id)
114
117
  : undefined;
118
+ const externalConversationName = conv?.external_conversation_name
119
+ ? String(conv.external_conversation_name)
120
+ : undefined;
115
121
  const externalMessageId = msg?.external_message_id ? String(msg.external_message_id) : undefined;
116
122
 
117
123
  // Progress reporting — identical ordering contract to the message path
@@ -178,7 +184,10 @@ export async function effectsForChannelDispatch(
178
184
 
179
185
  // Live capability grant → this turn's lark-cli auth env (mint gate = the
180
186
  // no-drift revocation point) + the hint teaching the single reply path.
181
- const capabilityEnv = await channelCapabilityEnv(ctx);
187
+ // The mint is Feishu-only. Asking for it on a Slack or WeChat inbound buys
188
+ // nothing (those replies are platform verbs under the agent's own identity)
189
+ // and would put a Feishu credential in a turn that has no use for one.
190
+ const capabilityEnv = provider === 'feishu' ? await channelCapabilityEnv(ctx) : null;
182
191
 
183
192
  return [
184
193
  {
@@ -201,6 +210,7 @@ export async function effectsForChannelDispatch(
201
210
  provider,
202
211
  conversationType: conv?.conversation_type ? String(conv.conversation_type) : undefined,
203
212
  externalConversationId,
213
+ externalConversationName,
204
214
  externalMessageId,
205
215
  senderName,
206
216
  text,
@@ -210,6 +220,7 @@ export async function effectsForChannelDispatch(
210
220
  channelMessageId: sourceId,
211
221
  provider,
212
222
  externalConversationId,
223
+ externalConversationName,
213
224
  externalMessageId,
214
225
  },
215
226
  // No chatId on purpose: the reply is the agent's own lark-cli send,
@@ -272,14 +283,18 @@ async function resolveChannelProvider(
272
283
  const cacheKey = `channelProvider:${connectionId}`;
273
284
  const cached = await ctx.store?.get(cacheKey).catch(() => null);
274
285
  if (typeof cached === 'string' && cached) return cached;
275
- try {
276
- const res = await get(`/channel-connections/${connectionId}`, PROVIDER_LOOKUP_TIMEOUT_MS);
277
- if (!res.ok) return undefined;
278
- const body = (await res.json().catch(() => null)) as { provider?: string } | null;
279
- const provider = body?.provider ? String(body.provider) : undefined;
280
- if (provider) await ctx.store?.set(cacheKey, provider).catch(() => {});
281
- return provider;
282
- } catch {
283
- return undefined;
286
+ const res = await get(`/channel-connections/${connectionId}`, PROVIDER_LOOKUP_TIMEOUT_MS);
287
+ // A gone connection is an ANSWER (no provider); anything else is the
288
+ // platform failing to answer. That distinction used to be moot — the
289
+ // provider was prompt wording but it now decides which outbound path the
290
+ // turn is taught, and a turn taught nothing still completes its source and
291
+ // is never revisited. Throwing leaves the dispatch for the catch-up pass.
292
+ if (res.status === 404 || res.status === 410) return undefined;
293
+ if (!res.ok) {
294
+ throw new Error(`channel connection lookup failed (${res.status})`);
284
295
  }
296
+ const body = (await res.json().catch(() => null)) as { provider?: string } | null;
297
+ const provider = body?.provider ? String(body.provider) : undefined;
298
+ if (provider) await ctx.store?.set(cacheKey, provider).catch(() => {});
299
+ return provider;
285
300
  }
package/src/inbound.ts CHANGED
@@ -5,8 +5,6 @@ import type {
5
5
  ConnectorEffect,
6
6
  WebSocketFrame,
7
7
  } from '@parel/plugin-sdk';
8
- import { channelCapabilityEnv } from './channel-capability.js';
9
- import { buildChatPrompt, type PromptAttachment } from './channel-prompt.js';
10
8
  import {
11
9
  armCatchUpEffect,
12
10
  beginCatchUp,
@@ -14,8 +12,9 @@ import {
14
12
  deferToCatchUp,
15
13
  runCatchUpRound,
16
14
  } from './catchup.js';
15
+ import { channelCapabilityEnv } from './channel-capability.js';
16
+ import { buildChatPrompt, type PromptAttachment } from './channel-prompt.js';
17
17
  import { parallApiUrl, parallOrgId, requireAgk, sandboxEnabled } from './connect.js';
18
- import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
19
18
  import {
20
19
  buildForkScopePrefix,
21
20
  bumpForkRetryAttempts,
@@ -33,6 +32,7 @@ import {
33
32
  settleChildEvent,
34
33
  trackFollowUpAck,
35
34
  } from './fork.js';
35
+ import { effectsForChannelDispatch, PROVIDER_LOOKUP_TIMEOUT_MS } from './inbound-channel.js';
36
36
  import {
37
37
  ackByIdEffect,
38
38
  claimDispatchSource,
@@ -51,9 +51,9 @@ import {
51
51
  nextEnvelopeId,
52
52
  noteTurnEventsObserved,
53
53
  patchSession,
54
+ REQUEST_TIMEOUT_MS,
54
55
  readActiveEmit,
55
56
  reportingEnabled,
56
- REQUEST_TIMEOUT_MS,
57
57
  TURN_ENVELOPE_KEY,
58
58
  writeActiveEmit,
59
59
  } from './session.js';