-
Notifications
You must be signed in to change notification settings - Fork 0
fix(linear-slack): deliver Slack replies + guard the writeback-scope footgun #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,259 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * linear-slack handler (harness / VFS-navigation model). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Slack-native conversational Linear board assistant. It responds to every | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * human message in one dedicated channel (the SLACK_CHANNEL picker input) and | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * runs a claude harness inside the sandbox with the Linear VFS mounted — the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * model navigates `./linear` on demand (see persona.systemPrompt) instead of | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * having the whole board pre-loaded into context. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * The handler is thin: it gates the event, reconstructs the thread's | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * conversation history from memory (multi-turn), hands the turn to the harness, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * posts the harness's reply to Slack, and records the turn. DMs are not handled. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| defineAgent, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type WorkforceCtx, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type WorkforceProviderEvent, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from '@agentworkforce/runtime'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { slackClient } from '@relayfile/relay-helpers'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const MEMORY_TAG = 'linear-slack'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const HISTORY_LIMIT = 8; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface SlackMessage { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| channel: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ts: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| threadTs?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isBot: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| subtype?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface SlackClientLike { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| post(channel: string, text: string): Promise<{ channel: string; ts: string }>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reply(channel: string, threadTs: string, text: string): Promise<{ channel: string; ts: string }>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default defineAgent({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| triggers: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Channel-scoped watch paths are the wake gate: the cloud dispatcher | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // intersects these against the event's relayfile path BEFORE provisioning a | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // box, so this agent only wakes for the board channel — never for any other | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // channel or DM. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `${SLACK_CHANNEL}` is a deploy-time placeholder, NOT a JS template — the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // single quotes keep it literal in the bundle, and the cloud deploy | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // substitutes the picker-chosen channel id into the watch glob | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (AgentWorkforce/cloud#1999). Hence single quotes, not backticks. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| slack: [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| on: 'message.created', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| paths: ['/slack/channels/${SLACK_CHANNEL}/**'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handler: async (ctx, event) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await handleSlackEvent(ctx, event, slackClient()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function handleSlackEvent( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx: WorkforceCtx, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event: WorkforceProviderEvent, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| slack: SlackClientLike, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (event.source !== 'slack') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logSkip(ctx, event, 'non-slack event source'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const msg = readSlackMessage(event.payload); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!msg) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logSkip(ctx, event, 'unparseable slack payload'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Loop guard: never react to bot messages (including our own replies). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (msg.isBot) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logSkip(ctx, event, 'bot message'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only fresh human messages — skip edits/deletes/joins/etc. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (msg.subtype) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logSkip(ctx, event, `slack subtype ${msg.subtype}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The trigger paths already gate the wake to the board channel; defense in | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // depth. The deploy resolved SLACK_CHANNEL to the same id it interpolated into | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // the watch path, so the runtime input is the source of truth. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const boardChannel = input(ctx, 'SLACK_CHANNEL'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (boardChannel && msg.channel !== boardChannel) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logSkip(ctx, event, 'not the board channel', { channel: msg.channel }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const text = stripLeadingMention(msg.text).trim(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!text) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logSkip(ctx, event, 'empty message text'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const convKey = `${msg.channel}:${msg.threadTs ?? msg.ts}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const history = await recallThread(ctx, convKey); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const prompt = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| history.length ? `Conversation so far:\n${history.join('\n')}\n` : '', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `Teammate just said:\n${text}`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(Boolean) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join('\n'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let reply: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const result = await ctx.harness.run({ prompt, cwd: ctx.sandbox.cwd }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reply = result.output.trim() || "I looked but don't have anything to add on that."; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.log?.('warn', 'linear-slack.harness.failed', { error: errorMessage(err) }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reply = isTransientLlmError(err) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? "I'm getting rate-limited by the model right now — give me a moment and ask again." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : 'Sorry, I hit an unexpected error working on that. Please try again.'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Best-effort apology — we're already in the error path, so don't let an | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // undelivered apology mask the original failure; just log it loudly. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await postReply(slack, msg, reply); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (postErr) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.log?.('error', 'linear-slack.reply.undelivered', { error: errorMessage(postErr) }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+118
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the harness fails, the agent catches the error, sends a best-effort apology, and returns successfully. This causes the platform to log
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await postReply(slack, msg, reply); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await rememberTurn(ctx, convKey, 'user', text); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await rememberTurn(ctx, convKey, 'assistant', reply); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Post the reply and CONFIRM it was delivered. `slackClient` doesn't call the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Slack API — it writes a draft into the VFS mount and polls for a writeback | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * receipt. When the receipt never arrives (writeback path not mounted, mount | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * degraded, box torn down before flush) it RESOLVES with an empty `ts` instead | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * of throwing — relay-helpers swallows the timeout. An empty `ts` therefore | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * means "Slack never got this." Throw so the runtime logs `handler.error` and | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * the dropped reply is visible, instead of a silent no-op that logs | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * `handler.ok` with nothing in the channel (linear-slack investigation | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 2026-06-09: a whole turn's reply was orphaned as an unflushed draft). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function postReply(slack: SlackClientLike, msg: SlackMessage, text: string): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const result = msg.threadTs | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? await slack.reply(msg.channel, msg.threadTs, text) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : await slack.post(msg.channel, text); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!result?.ts) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+150
to
+153
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the user sends a top-level message ( async function postReply(slack: SlackClientLike, msg: SlackMessage, text: string): Promise<void> {
const result = await slack.reply(msg.channel, msg.threadTs ?? msg.ts, text); |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `slack ${msg.threadTs ? 'reply' : 'post'} to ${msg.channel} was not delivered ` + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '(writeback returned no receipt — is the /slack/channels subtree mounted?)', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /* ---------- Slack payload reading ---------- */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function readSlackMessage(payload: unknown): SlackMessage | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rec = unwrapRecord(payload); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!rec) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const raw = asRecord(rec.raw_event) ?? rec; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const channel = str(rec.channel) ?? str(raw.channel); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!channel) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ts = str(rec.ts) ?? str(raw.ts) ?? str(rec.event_ts) ?? str(raw.event_ts); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!ts) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| channel, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ts, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| threadTs: str(rec.thread_ts) ?? str(rec.threadTs) ?? str(raw.thread_ts), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| text: str(rec.text) ?? str(raw.text) ?? '', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| user: str(rec.user) ?? str(raw.user), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isBot: Boolean(rec.is_bot ?? raw.is_bot) || Boolean(str(rec.bot_id) ?? str(raw.bot_id)), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| subtype: str(rec.subtype) ?? str(raw.subtype), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** Slack events may arrive wrapped as { resource: { payload | record } } or flat. */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function unwrapRecord(payload: unknown): Record<string, unknown> | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const record = asRecord(payload); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!record) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const resource = asRecord(record.resource) ?? record; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return asRecord(resource.payload) ?? asRecord(resource.record) ?? resource; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function stripLeadingMention(text: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return text.replace(/^\s*<@[^>\s]+>\s*/u, ''); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /* ---------- memory (multi-turn) ---------- */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function recallThread(ctx: WorkforceCtx, convKey: string): Promise<string[]> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const items = await ctx.memory.recall(convKey, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| scope: 'workspace', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tags: [MEMORY_TAG, convKey], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| limit: HISTORY_LIMIT, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return items.map((item) => item.content); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.log?.('warn', 'linear-slack.memory.recall.failed', { error: errorMessage(err) }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return []; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function rememberTurn( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx: WorkforceCtx, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| convKey: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| role: 'user' | 'assistant', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!body.trim()) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await ctx.memory.save(`${role}: ${body}`, { scope: 'workspace', tags: [MEMORY_TAG, convKey] }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.log?.('warn', 'linear-slack.memory.save.failed', { error: errorMessage(err) }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /* ---------- small helpers ---------- */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function input(ctx: WorkforceCtx, name: string): string | undefined { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const spec = ctx.persona.inputSpecs?.[name]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const value = process.env[spec?.env ?? name] ?? ctx.persona.inputs?.[name] ?? spec?.default; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return value && value.trim() ? value.trim() : undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function isTransientLlmError(error: unknown): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return /\b429\b|rate_limit|\b50[0-9]\b|overloaded|timeout/i.test(errorMessage(error)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function str(value: unknown): string | undefined { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return typeof value === 'string' && value.trim() ? value : undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function asRecord(value: unknown): Record<string, unknown> | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return value && typeof value === 'object' && !Array.isArray(value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? (value as Record<string, unknown>) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function errorMessage(error: unknown): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return error instanceof Error ? error.message : String(error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function logSkip( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx: WorkforceCtx, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event: WorkforceProviderEvent, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reason: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attrs: Record<string, unknown> = {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx.log?.('info', 'linear-slack skipped', { eventId: event.id, type: event.type, reason, ...attrs }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,108 @@ | ||||||
| import { definePersona } from '@agentworkforce/persona-kit'; | ||||||
|
|
||||||
| /** | ||||||
| * linear-slack — a conversational Linear board assistant you chat with in one | ||||||
| * dedicated Slack channel. | ||||||
| * | ||||||
| * Unlike a one-shot `ctx.llm` agent, this runs a **claude harness inside a | ||||||
| * sandbox** with the Linear VFS mounted, so the model *navigates* the board on | ||||||
| * demand (reads only the issues it needs) instead of having every issue stuffed | ||||||
| * into its context. It can also organize the board by editing the mounted | ||||||
| * Linear files (writeback). The claude harness runs on your connected Anthropic | ||||||
| * subscription. | ||||||
| */ | ||||||
| export default definePersona({ | ||||||
| id: 'linear-slack', | ||||||
| intent: 'relay-orchestrator', | ||||||
| tags: ['planning'], | ||||||
| description: | ||||||
| 'Chat in a Slack channel about open Linear issues and organize the board — navigates the mounted Linear VFS to read and update issues on demand.', | ||||||
|
|
||||||
| cloud: true, | ||||||
| // Boot a sandbox and mount the relayfile VFS so the harness can navigate | ||||||
| // /linear on demand. (A read-mostly ctx.llm agent would have to pre-load every | ||||||
| // issue into the prompt — the token bloat that was tripping the rate limit.) | ||||||
| sandbox: true, | ||||||
| // claude harness → your connected Anthropic subscription. | ||||||
| useSubscription: true, | ||||||
| memory: true, | ||||||
|
|
||||||
| integrations: { | ||||||
| // Cloud mounts an integration's relayfile subtree only from `scope` (or | ||||||
| // from a trigger's watch path). The slack trigger here only mirrors the | ||||||
| // ONE board channel READ-ONLY at the display-labelled path | ||||||
| // `/slack/channels/{id}__{name}/messages` — but `slackClient().post()` | ||||||
| // writes its draft to the canonical bare-id writeback path | ||||||
| // `/slack/channels/{id}/messages`, which a scope-less `slack: {}` never | ||||||
| // mounts. So every reply landed on unmounted local disk and the writeback | ||||||
| // worker never flushed it: replies were a silent no-op. Mount the channels | ||||||
| // subtree (same fix as review/vendor-monitor/hn-monitor/repo-hygiene) so | ||||||
| // the writeback path exists for whichever channel the picker resolves. | ||||||
| slack: { scope: { paths: '/slack/channels/**' } }, | ||||||
| // Scope Linear to concrete SUBPATHS, not the provider root. Three gotchas | ||||||
| // stack here: a bare `linear: {}` grants no scope; persona-kit drops an | ||||||
| // empty `scope: {}`; and the cloud runtime mount deliberately DROPS | ||||||
| // provider-root globs (`/linear/**`) to avoid mirroring whole providers | ||||||
| // (isProviderRootPath). So `/linear/**` would be granted in the token but | ||||||
| // never mirrored into the sandbox. Naming subpaths (`/linear/issues/**`, | ||||||
| // …) survives that filter, so the harness actually sees `./linear/issues`. | ||||||
| linear: { | ||||||
| scope: { | ||||||
| issues: '/linear/issues/**', | ||||||
| teams: '/linear/teams/**', | ||||||
| projects: '/linear/projects/**', | ||||||
| // The self-describing `/linear/LAYOUT.md` lives at the provider ROOT, which | ||||||
| // the broad `/linear/**` mount would cover — but provider-root globs are | ||||||
| // dropped from the mirror (isProviderRootPath), so scope the file directly. | ||||||
| layout: '/linear/LAYOUT.md', | ||||||
| }, | ||||||
| }, | ||||||
| }, | ||||||
|
|
||||||
| inputs: { | ||||||
| // The board-chat channel, chosen via a Slack picker at deploy time. Its id | ||||||
| // is interpolated into the agent's trigger watch path so the dispatcher only | ||||||
| // wakes this agent for that channel (AgentWorkforce/cloud#1999). | ||||||
| SLACK_CHANNEL: { | ||||||
| description: 'The Slack channel the bot chats in about the Linear board.', | ||||||
| env: 'SLACK_CHANNEL', | ||||||
| picker: { provider: 'slack', resource: 'channels' }, | ||||||
| }, | ||||||
| }, | ||||||
|
|
||||||
| harness: 'claude', | ||||||
| model: 'claude-sonnet-4-6', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The model name
Suggested change
|
||||||
| systemPrompt: [ | ||||||
| 'You are a Linear board assistant chatting with a teammate in a Slack channel.', | ||||||
| '', | ||||||
| 'The team Linear data is mounted READ/WRITE. `./linear/LAYOUT.md` is the self-describing', | ||||||
| 'map of the mount — skim it if unsure. Everything you need for issues is under', | ||||||
| '`./linear/issues/`:', | ||||||
| '- `_index.json` — the full index of every issue. START HERE.', | ||||||
| '- flat issue files named `<REF>__<uuid>.json` (e.g. `AR-10__<uuid>.json`).', | ||||||
| '- alias views: `by-id/`, `by-state/<state>/` (`backlog`, `in-progress`, `done`,', | ||||||
| ' `canceled`, …), `by-priority/`, `by-edited/`, `by-title/`.', | ||||||
| '', | ||||||
| 'To list OPEN issues, enumerate the COMPLETE set — read `_index.json`, or list every', | ||||||
| 'state dir under `by-state/` EXCEPT `done`/`canceled` — and include ALL of them, not a', | ||||||
| 'sample or just the most recent. Never invent an issue without reading its file. Read only', | ||||||
| 'what the question needs, but "all open issues" means all of them.', | ||||||
| '', | ||||||
| 'To ORGANIZE the board, only when the teammate clearly asks for a change. The mount', | ||||||
| 'writes back to Linear; check the resource `.schema.json` for writable fields first', | ||||||
| '(fields marked `readOnly` are server-managed). Edit the canonical issue file (e.g.', | ||||||
| '`./linear/issues/by-id/<ID>.json`) to change state/assignee/title; add a comment by', | ||||||
| 'writing a new JSON file under that issue’s `comments/` directory.', | ||||||
| 'Make the smallest change asked for. If they are only discussing, propose a plan and do not edit.', | ||||||
| '', | ||||||
| 'Reply with concise, Slack-friendly plain text (Linear refs like ENG-12 are welcome).', | ||||||
| 'Do NOT post to Slack yourself — your final stdout is sent back as the reply.', | ||||||
| ].join('\n'), | ||||||
| harnessSettings: { | ||||||
| reasoning: 'medium', | ||||||
| // The harness has to boot, mount, navigate, and (sometimes) write — give it room. | ||||||
| timeoutSeconds: 300, | ||||||
| }, | ||||||
|
|
||||||
| onEvent: './agent.ts', | ||||||
| }); | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Skipping all messages with any
subtypewill cause the agent to silently ignore valid human messages that have subtypes, such asfile_share(when a user uploads an image/file with a comment) orthread_broadcast(when a user cross-posts a thread reply to the channel). Instead of skipping all subtypes, consider skipping only specific system or edit subtypes (e.g.,message_changed,message_deleted,channel_join,channel_leave,tombstone).