From 9d65b70242b2a51aaf3f7c3c83439cf17fbe84f6 Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sun, 7 Jun 2026 18:52:57 -0400 Subject: [PATCH] Add per-GM event scoping to channel templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume ty's new assigned_gm field (TASK_ASSIGNED_GM env var, shipped in bborn/taskyou PR #561) so a single TaskYou daemon shared by multiple GMs can scope task events per GM. Channel template (templates/channel/taskyou-channel.ts.tmpl): - Add GM_SLUG (process.env.GM_SLUG || GM_ALIAS, defaulting to the setup-time {{GM_ALIAS}}) and SEE_UNASSIGNED (default true) consts. - Auto-inject `--assigned-gm ` on `ty create` only, idempotent (skipped if the caller already passed --assigned-gm). - Filter emitted notifications: emit when assigned_gm === GM_SLUG, or when unassigned (missing/blank) and SEE_UNASSIGNED. Malformed lines are treated as unassigned. The line cursor always advances; only the emit is gated. - Surface assigned_gm in the notification meta. Hook templates (task.completed, task.blocked): - Emit assigned_gm (from $TASK_ASSIGNED_GM) in the JSON line. config.example.env: - Document GM_SLUG (derives from GM_ALIAS) and SEE_UNASSIGNED. Additive only; existing behavior — including the SSH/remote path — is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- config.example.env | 16 ++++++ templates/channel/taskyou-channel.ts.tmpl | 68 +++++++++++++++++------ templates/hooks/task.blocked.tmpl | 3 +- templates/hooks/task.completed.tmpl | 3 +- 4 files changed, 72 insertions(+), 18 deletions(-) diff --git a/config.example.env b/config.example.env index 2df7d2e..047fef4 100644 --- a/config.example.env +++ b/config.example.env @@ -90,3 +90,19 @@ PROJECT_DESCRIPTION="My Project does X, Y, and Z." # Hosts agents can reach through the credential proxy. # Only these hosts receive injected credentials. # NONO_PROXY_HOSTS="api.linear.app,api.github.com" + +# === Optional: Per-GM event scoping === +# When multiple GMs share one TaskYou daemon, the channel can scope task +# events to a single GM. The channel stamps tasks it creates with +# `--assigned-gm ` (consuming ty's assigned_gm field) and only +# surfaces events whose assigned_gm matches this GM. +# +# GM_SLUG defaults to GM_ALIAS (set above), so no extra config is needed for +# the common single-GM case. Override it only if you want the assignment slug +# to differ from the launch alias. +# GM_SLUG="mygm" +# +# SEE_UNASSIGNED controls whether events with no assigned_gm (tasks created +# outside the channel, or emitted by an older ty without the field) are still +# shown to this GM. Defaults to "true". Set to "false" for strict isolation. +# SEE_UNASSIGNED="true" diff --git a/templates/channel/taskyou-channel.ts.tmpl b/templates/channel/taskyou-channel.ts.tmpl index 0e68ae8..c0509d9 100644 --- a/templates/channel/taskyou-channel.ts.tmpl +++ b/templates/channel/taskyou-channel.ts.tmpl @@ -14,6 +14,14 @@ const SERVER_HOST = "{{SERVER_HOST}}"; const SERVER_HOME = "{{SERVER_HOME}}"; const POLL_INTERVAL_MS = 10_000; // 10 seconds +// Per-GM event scoping. GM_SLUG identifies this GM (defaults to the GM_ALIAS +// chosen at setup time). New tasks created through this channel are stamped +// with --assigned-gm , and emitted notifications are filtered to this +// GM. SEE_UNASSIGNED controls whether events with no assigned_gm (e.g. tasks +// created outside the channel, or older ty versions) are still surfaced here. +const GM_SLUG = process.env.GM_SLUG || process.env.GM_ALIAS || "{{GM_ALIAS}}"; +const SEE_UNASSIGNED = (process.env.SEE_UNASSIGNED ?? "true") !== "false"; + // Track where we are in the notifications file let lastLineCount = 0; @@ -81,7 +89,15 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args } = req.params; if (name === "ty_command") { - const { args: tyArgs } = args as { args: string }; + let { args: tyArgs } = args as { args: string }; + // Scope new tasks to this GM: inject --assigned-gm on `create` (only), + // unless the caller already specified one. Idempotent. + if ( + /^\s*create(\s|$)/.test(tyArgs) && + !/--assigned-gm(\s|=)/.test(tyArgs) + ) { + tyArgs = `${tyArgs} --assigned-gm ${GM_SLUG}`; + } const result = await runRemote(`ty ${tyArgs}`); return { content: [{ type: "text", text: result }] }; } @@ -147,24 +163,44 @@ async function pollNotifications() { const trimmed = line.trim(); if (!trimmed) continue; + let event: any; try { - const event = JSON.parse(trimmed); - await mcp.notification({ - method: "notifications/claude/channel", - params: { - content: trimmed, - meta: { - event: event.event || "unknown", - task_id: event.task_id || "", - title: event.title || "", - project: event.project || "", - timestamp: event.timestamp || "", - }, - }, - }); + event = JSON.parse(trimmed); } catch { - // Skip malformed lines + // Malformed line → treat as unassigned. The cursor still advances + // below (we iterate every line); only the emit is gated here. + if (SEE_UNASSIGNED) { + await mcp.notification({ + method: "notifications/claude/channel", + params: { content: trimmed, meta: {} }, + }); + } + continue; } + + // Per-GM scoping: emit if assigned to this GM, or if unassigned and + // this session opts into seeing unassigned events. A missing/blank + // assigned_gm is treated as unassigned. + const assignedGm = event.assigned_gm; + const isMine = assignedGm === GM_SLUG; + const isUnassigned = + assignedGm === undefined || assignedGm === null || assignedGm === ""; + if (!isMine && !(isUnassigned && SEE_UNASSIGNED)) continue; + + await mcp.notification({ + method: "notifications/claude/channel", + params: { + content: trimmed, + meta: { + event: event.event || "unknown", + task_id: event.task_id || "", + title: event.title || "", + project: event.project || "", + assigned_gm: event.assigned_gm || "", + timestamp: event.timestamp || "", + }, + }, + }); } } diff --git a/templates/hooks/task.blocked.tmpl b/templates/hooks/task.blocked.tmpl index 714ee6c..004aa95 100644 --- a/templates/hooks/task.blocked.tmpl +++ b/templates/hooks/task.blocked.tmpl @@ -7,6 +7,7 @@ NOTIFICATIONS_FILE="{{SERVER_HOME}}/notifications.jsonl" TASK_ID="${TASK_ID:-unknown}" TASK_TITLE="${TASK_TITLE:-}" TASK_PROJECT="${TASK_PROJECT:-}" +TASK_ASSIGNED_GM="${TASK_ASSIGNED_GM:-}" TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -echo "{\"event\":\"blocked\",\"task_id\":\"${TASK_ID}\",\"title\":$(printf '%s' "$TASK_TITLE" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"project\":\"${TASK_PROJECT}\",\"timestamp\":\"${TIMESTAMP}\"}" >> "$NOTIFICATIONS_FILE" +echo "{\"event\":\"blocked\",\"task_id\":\"${TASK_ID}\",\"title\":$(printf '%s' "$TASK_TITLE" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"project\":\"${TASK_PROJECT}\",\"assigned_gm\":\"${TASK_ASSIGNED_GM}\",\"timestamp\":\"${TIMESTAMP}\"}" >> "$NOTIFICATIONS_FILE" diff --git a/templates/hooks/task.completed.tmpl b/templates/hooks/task.completed.tmpl index 93cfb0e..db6d935 100644 --- a/templates/hooks/task.completed.tmpl +++ b/templates/hooks/task.completed.tmpl @@ -7,6 +7,7 @@ NOTIFICATIONS_FILE="{{SERVER_HOME}}/notifications.jsonl" TASK_ID="${TASK_ID:-unknown}" TASK_TITLE="${TASK_TITLE:-}" TASK_PROJECT="${TASK_PROJECT:-}" +TASK_ASSIGNED_GM="${TASK_ASSIGNED_GM:-}" TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") -echo "{\"event\":\"completed\",\"task_id\":\"${TASK_ID}\",\"title\":$(printf '%s' "$TASK_TITLE" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"project\":\"${TASK_PROJECT}\",\"timestamp\":\"${TIMESTAMP}\"}" >> "$NOTIFICATIONS_FILE" +echo "{\"event\":\"completed\",\"task_id\":\"${TASK_ID}\",\"title\":$(printf '%s' "$TASK_TITLE" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"project\":\"${TASK_PROJECT}\",\"assigned_gm\":\"${TASK_ASSIGNED_GM}\",\"timestamp\":\"${TIMESTAMP}\"}" >> "$NOTIFICATIONS_FILE"