larkin 0.4.8 → 0.4.10

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.
package/README.md CHANGED
@@ -87,6 +87,8 @@ Feishu clients do not reliably render Markdown links such as `[label](URL)` as c
87
87
 
88
88
  ## Setup and configuration
89
89
 
90
+ Feishu (https://open.feishu.cn) and Lark (https://open.larksuite.com) are different platforms; `larkin setup` must be told which brand with `--tenant feishu|lark` or the interactive prompt before the authorization QR, and must never emit a `feishu.cn` host for a Lark tenant.
91
+
90
92
  During setup, a new Agent is offered Pi first, followed by Codex and Claude Code. Provider keys are stored only in the selected Agent's private provider directory, not in the ordinary Agent config. Setup also discovers every API-key and OAuth/subscription login exposed by the pinned official Pi registry and delegates those flows to Pi.
91
93
 
92
94
  Use `larkin pi-auth status` or `larkin pi-auth logout <provider>` to manage Pi auth. The builtin Pi runtime loads Larkin's two supported extensions inline: background subagents and the 60-second foreground bash timeout guard are enabled without writing extension files or arguments. A compatible external Pi installation keeps the existing explicit `-e` extension path.
@@ -16,7 +16,7 @@ const agent_cli_capabilities_js_1 = require("./agent-cli-capabilities.cjs");
16
16
  * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
17
17
  * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
18
18
  */
19
- exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v20";
19
+ exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v21";
20
20
  /**
21
21
  * Agent 间协作唤醒引导(issue #75):纯文本 @ 不会产生飞书 mention 事件,
22
22
  * 必须由模型直接构造真实的 mention 元素;`--mention` 扩展参数已移除。
@@ -126,6 +126,7 @@ class ContextPromptBuilder {
126
126
  "## Feishu IM command map",
127
127
  "",
128
128
  `Use only the Larkin-owned \`${executable}\` command for Feishu. Bot identity, private configuration, and freshness are Runtime-bound; the wrapper delegates to the unmodified global official CLI. Use \`${executable} <command> --help\`, never invoke bare \`lark-cli\`, and never pass \`--agent\`, \`--as user\`, \`--profile\`, or \`--config-dir\`. If a command reports a missing scope, relay that error unchanged and ask the user to authorize it; do not bypass the scope boundary.`,
129
+ "If a platform URL must be shown, use the current Agent tenant host; never emit feishu.cn for a Lark tenant.",
129
130
  `Use the exact Inbox target for history reads. For \`thread:<chat_id>:<thread_id>\`, run \`${executable} im +threads-messages-list --thread <thread_id> --order desc --page-size 10 --no-reactions --json\`. For \`chat:<chat_id>\`, run \`${executable} im +chat-messages-list --chat-id <chat_id> --order desc --page-size 10 --no-reactions --json\`.`,
130
131
  "Successful history response messages are always at `data.messages`. Never use a chat-wide fallback for a thread target, never merge stderr with `2>&1` before parsing JSON, and never truncate structured output before parsing it.",
131
132
  "If the scoped history read fails or its schema is invalid, fail visibly. Do not reuse remembered or hard-coded text to make the task appear successful.",
@@ -13,7 +13,7 @@ import { agentCliPromptCapabilities } from "./agent-cli-capabilities.mjs";
13
13
  * 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
14
14
  * 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
15
15
  */
16
- export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v20";
16
+ export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v21";
17
17
  /**
18
18
  * Agent 间协作唤醒引导(issue #75):纯文本 @ 不会产生飞书 mention 事件,
19
19
  * 必须由模型直接构造真实的 mention 元素;`--mention` 扩展参数已移除。
@@ -123,6 +123,7 @@ export class ContextPromptBuilder {
123
123
  "## Feishu IM command map",
124
124
  "",
125
125
  `Use only the Larkin-owned \`${executable}\` command for Feishu. Bot identity, private configuration, and freshness are Runtime-bound; the wrapper delegates to the unmodified global official CLI. Use \`${executable} <command> --help\`, never invoke bare \`lark-cli\`, and never pass \`--agent\`, \`--as user\`, \`--profile\`, or \`--config-dir\`. If a command reports a missing scope, relay that error unchanged and ask the user to authorize it; do not bypass the scope boundary.`,
126
+ "If a platform URL must be shown, use the current Agent tenant host; never emit feishu.cn for a Lark tenant.",
126
127
  `Use the exact Inbox target for history reads. For \`thread:<chat_id>:<thread_id>\`, run \`${executable} im +threads-messages-list --thread <thread_id> --order desc --page-size 10 --no-reactions --json\`. For \`chat:<chat_id>\`, run \`${executable} im +chat-messages-list --chat-id <chat_id> --order desc --page-size 10 --no-reactions --json\`.`,
127
128
  "Successful history response messages are always at `data.messages`. Never use a chat-wide fallback for a thread target, never merge stderr with `2>&1` before parsing JSON, and never truncate structured output before parsing it.",
128
129
  "If the scoped history read fails or its schema is invalid, fail visibly. Do not reuse remembered or hard-coded text to make the task appear successful.",
package/dist/app/cli.mjs CHANGED
@@ -49,8 +49,8 @@ if (runtimeAgentAuthority && command === "comment")
49
49
  const commandHelp = {
50
50
  start: `Usage: larkin start [--agent <App ID> | --agents <App ID,...>]
51
51
  Start one foreground supervisor for the daemon and local dashboard, or reuse it.`,
52
- setup: `Usage: larkin setup [--runtime <builtin-pi|external-pi|codex|claude>] [--provider <id> --api-key <key>]
53
- Run setup to create or connect a bot, configure its Agent, and attach it. Interactive terminals keep the guided flow; Agent-driven (non-TTY) runs default to builtin-pi and need --provider/--api-key (or --runtime external-pi).`,
52
+ setup: `Usage: larkin setup [--tenant feishu|lark] [--runtime <builtin-pi|external-pi|codex|claude>] [--provider <id> --api-key <key>]
53
+ Run setup to create or connect a bot, configure its Agent, and attach it. Choose Feishu or Lark before the authorization QR (--tenant; default feishu). Interactive terminals keep the guided flow; Agent-driven (non-TTY) runs default to builtin-pi and need --provider/--api-key (or --runtime external-pi).`,
54
54
  status: `Usage: larkin status [--json]
55
55
  Show Agent configuration, bot identity, credentials, and connection status. Use --json for readiness automation.`,
56
56
  agents: `Usage: larkin agents [--json]
@@ -9,6 +9,7 @@ import { assertSecureBotsDirectory, readSecureBotCredential, validCredentialReco
9
9
  import { resolveOfficialLarkCli } from "./official-lark-cli.mjs";
10
10
  import { internalCommandSpec } from "./internal-command.mjs";
11
11
  import { assertAgentWorkspaceBound, larkChannelSourceConfigPath, larkChannelWorkspaceConfigPath, managedLarkCliEnv, } from "./agent-lark-cli-workspace.mjs";
12
+ import { accountTenantFromOpenHost, openPlatformHost, } from "../feishu/platform-hosts.mjs";
12
13
  function assertSecureProfileDirectory(directory) {
13
14
  const stat = fs.lstatSync(directory);
14
15
  if (!stat.isDirectory() || stat.isSymbolicLink()
@@ -140,7 +141,7 @@ export function hydrateRuntimeAgent(configDir, agent) {
140
141
  return {
141
142
  ...agent,
142
143
  feishuAppSecret: credential.appSecret,
143
- feishuDomain: credential.tenant === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn",
144
+ feishuDomain: openPlatformHost(credential.tenant),
144
145
  credentialRevision,
145
146
  };
146
147
  }
@@ -248,7 +249,7 @@ export function installRuntimeCommandShims(agent) {
248
249
  }
249
250
  return commandDir;
250
251
  }
251
- function sourceProjection(agent, env) {
252
+ export function sourceProjection(agent, env) {
252
253
  const configDir = path.resolve(env.LARKIN_CONFIG_DIR || "");
253
254
  if (!configDir)
254
255
  throw new Error("LARKIN_CONFIG_DIR required for lark-channel source projection");
@@ -261,7 +262,7 @@ function sourceProjection(agent, env) {
261
262
  accounts: { app: {
262
263
  id: agent.feishuAppId,
263
264
  secret: { source: "exec", provider: "larkin-bot-credential", id: agent.feishuAppId },
264
- tenant: agent.feishuDomain === "https://open.larksuite.com" ? "lark" : "feishu",
265
+ tenant: accountTenantFromOpenHost(agent.feishuDomain),
265
266
  } },
266
267
  secrets: { providers: { "larkin-bot-credential": {
267
268
  source: "exec", command: provider.command, args: provider.args,
@@ -294,6 +295,9 @@ function validateSourceProjection(file, agent) {
294
295
  throw new Error("lark-channel source projection 不得包含 plaintext secret 字段");
295
296
  if (root.credentialRevision !== agent.credentialRevision)
296
297
  throw new Error(`Agent ${agent.agentId} lark-channel credential revision mismatch`);
298
+ if (app?.tenant !== accountTenantFromOpenHost(agent.feishuDomain)) {
299
+ throw new Error(`Agent ${agent.agentId} lark-channel source projection tenant mismatch`);
300
+ }
297
301
  }
298
302
  export function syncAgentProfile(agent, env, dependencies = {}) {
299
303
  const expected = path.join(path.resolve(env.LARKIN_CONFIG_DIR || ""), "state", "agents", agent.agentId, "lark-cli-config");
@@ -82,6 +82,11 @@ Options:
82
82
  --base-url <url> custom 端点(provider=custom 时必填;也可覆盖 preset 默认端点)
83
83
  --model <id> 模型 ID;默认取 provider 预设。不同 runtime 的模型可用
84
84
  \`larkin model\` 查看 / 切换
85
+ --tenant feishu|lark 授权二维码之前选择品牌。默认 feishu(accounts.feishu.cn /
86
+ https://open.feishu.cn)。Lark 用 --tenant lark
87
+ (accounts.larksuite.com / https://open.larksuite.com)。
88
+ 交互式未指定时会先询问。飞书与 Lark 是不同平台,
89
+ Lark 租户不得使用 feishu.cn 主机。
85
90
 
86
91
  setup handles browser authorization, permission grants, credential storage, Agent configuration,
87
92
  and target-only hot attach. Each Agent is identified by its bot App ID: selecting the same bot reuses
@@ -152,6 +157,12 @@ export async function main() {
152
157
  registerArgs.push("--pi-distribution", normalizedRuntime.distribution);
153
158
  }
154
159
  registerArgs.push("--comment-subscription", OPT.commentSubscription);
160
+ if (has("--tenant")) {
161
+ const tenant = flag("--tenant");
162
+ if (tenant !== "feishu" && tenant !== "lark")
163
+ die("--tenant 只支持 feishu 或 lark");
164
+ registerArgs.push("--tenant", tenant === "lark" ? "lark" : "feishu");
165
+ }
155
166
  for (const name of ["--provider", "--api-key", "--base-url", "--model"]) {
156
167
  const value = flag(name);
157
168
  if (value)
@@ -61,6 +61,7 @@ const internal_command_js_1 = require("../app/internal-command.cjs");
61
61
  const agent_lark_cli_workspace_js_1 = require("../app/agent-lark-cli-workspace.cjs");
62
62
  const agent_readiness_js_1 = require("../app/agent-readiness.cjs");
63
63
  const document_comment_js_1 = require("./document-comment.cjs");
64
+ const platform_hosts_js_1 = require("./platform-hosts.cjs");
64
65
  const PENDING_DOCUMENT_COMMENT_LIMIT = 256;
65
66
  function isRecord(value) {
66
67
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -152,7 +153,7 @@ function loadAgents(env, testEventSource, larkinHome, reconcileAgentWorkspaceImp
152
153
  }
153
154
  }
154
155
  if (!testEventSource && (typeof candidate.feishuAppSecret !== "string" || !candidate.feishuAppSecret.trim()
155
- || !["https://open.feishu.cn", "https://open.larksuite.com"].includes(String(candidate.feishuDomain)))) {
156
+ || !(0, platform_hosts_js_1.isOpenPlatformHost)(candidate.feishuDomain))) {
156
157
  throw new Error(`LARKIN_AGENTS_CONFIG Agent ${id} 缺少有效 channel 凭证/domain`);
157
158
  }
158
159
  const expectedWorkspace = node_path_1.default.join(larkinHome, "agents", id);
@@ -742,7 +743,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
742
743
  const channel = createLarkChannel({
743
744
  appId: agent.feishuAppId,
744
745
  appSecret: agent.feishuAppSecret,
745
- domain: agent.feishuDomain || "https://open.feishu.cn",
746
+ domain: (0, platform_hosts_js_1.requireOpenDomain)(agent.feishuDomain),
746
747
  source: "larkin",
747
748
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
748
749
  // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
@@ -857,7 +858,7 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
857
858
  const channel = createLarkChannel({
858
859
  appId: agent.feishuAppId,
859
860
  appSecret: agent.feishuAppSecret,
860
- domain: agent.feishuDomain || "https://open.feishu.cn",
861
+ domain: (0, platform_hosts_js_1.requireOpenDomain)(agent.feishuDomain),
861
862
  source: "larkin",
862
863
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
863
864
  // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
@@ -21,6 +21,7 @@ import { processCommandToken } from "../app/internal-command.mjs";
21
21
  import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
22
22
  import { isChannelReconnecting, isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
23
23
  import { documentCommentMessageId, documentCommentNoticeType, projectDocumentCommentEnvelope, resolveDocumentCommentContext, } from "./document-comment.mjs";
24
+ import { isOpenPlatformHost, requireOpenDomain } from "./platform-hosts.mjs";
24
25
  const PENDING_DOCUMENT_COMMENT_LIMIT = 256;
25
26
  function isRecord(value) {
26
27
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -112,7 +113,7 @@ function loadAgents(env, testEventSource, larkinHome, reconcileAgentWorkspaceImp
112
113
  }
113
114
  }
114
115
  if (!testEventSource && (typeof candidate.feishuAppSecret !== "string" || !candidate.feishuAppSecret.trim()
115
- || !["https://open.feishu.cn", "https://open.larksuite.com"].includes(String(candidate.feishuDomain)))) {
116
+ || !isOpenPlatformHost(candidate.feishuDomain))) {
116
117
  throw new Error(`LARKIN_AGENTS_CONFIG Agent ${id} 缺少有效 channel 凭证/domain`);
117
118
  }
118
119
  const expectedWorkspace = path.join(larkinHome, "agents", id);
@@ -702,7 +703,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
702
703
  const channel = createLarkChannel({
703
704
  appId: agent.feishuAppId,
704
705
  appSecret: agent.feishuAppSecret,
705
- domain: agent.feishuDomain || "https://open.feishu.cn",
706
+ domain: requireOpenDomain(agent.feishuDomain),
706
707
  source: "larkin",
707
708
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
708
709
  // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
@@ -817,7 +818,7 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
817
818
  const channel = createLarkChannel({
818
819
  appId: agent.feishuAppId,
819
820
  appSecret: agent.feishuAppSecret,
820
- domain: agent.feishuDomain || "https://open.feishu.cn",
821
+ domain: requireOpenDomain(agent.feishuDomain),
821
822
  source: "larkin",
822
823
  policy: { dmMode: "open", requireMention: false, respondToMentionAll: true },
823
824
  // issue #88:关闭 SDK 的防抖批量合并(默认 600ms),逐条投递,
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ // Official Feishu vs Lark hosts from @larksuite/cli ResolveEndpoints
3
+ // (open / accounts / applink) plus Open-host docs and console paths.
4
+ // Do not add larkoffice.com or any unofficial host.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PLATFORM_HOSTS = void 0;
7
+ exports.parseLarkinTenant = parseLarkinTenant;
8
+ exports.openPlatformHost = openPlatformHost;
9
+ exports.registerAppAccountsHost = registerAppAccountsHost;
10
+ exports.isOpenPlatformHost = isOpenPlatformHost;
11
+ exports.requireOpenDomain = requireOpenDomain;
12
+ exports.accountTenantFromOpenHost = accountTenantFromOpenHost;
13
+ exports.PLATFORM_HOSTS = {
14
+ feishu: {
15
+ open: "https://open.feishu.cn",
16
+ accounts: "https://accounts.feishu.cn",
17
+ applink: "https://applink.feishu.cn",
18
+ docs: "https://open.feishu.cn/document",
19
+ console: "https://open.feishu.cn/app",
20
+ },
21
+ lark: {
22
+ open: "https://open.larksuite.com",
23
+ accounts: "https://accounts.larksuite.com",
24
+ applink: "https://applink.larksuite.com",
25
+ docs: "https://open.larksuite.com/document",
26
+ console: "https://open.larksuite.com/app",
27
+ },
28
+ };
29
+ function parseLarkinTenant(value) {
30
+ return value === "feishu" || value === "lark" ? value : null;
31
+ }
32
+ function openPlatformHost(tenant) {
33
+ return exports.PLATFORM_HOSTS[tenant].open;
34
+ }
35
+ function registerAppAccountsHost(tenant) {
36
+ // node-sdk registerApp.domain / larkDomain interpolate https://${host}
37
+ return new URL(exports.PLATFORM_HOSTS[tenant].accounts).host;
38
+ }
39
+ function isOpenPlatformHost(value) {
40
+ return value === exports.PLATFORM_HOSTS.feishu.open || value === exports.PLATFORM_HOSTS.lark.open;
41
+ }
42
+ function requireOpenDomain(value) {
43
+ if (isOpenPlatformHost(value))
44
+ return value;
45
+ throw new Error("missing or invalid Open Platform domain");
46
+ }
47
+ function accountTenantFromOpenHost(domain) {
48
+ return domain === exports.PLATFORM_HOSTS.lark.open ? "lark" : "feishu";
49
+ }
@@ -0,0 +1,40 @@
1
+ // Official Feishu vs Lark hosts from @larksuite/cli ResolveEndpoints
2
+ // (open / accounts / applink) plus Open-host docs and console paths.
3
+ // Do not add larkoffice.com or any unofficial host.
4
+ export const PLATFORM_HOSTS = {
5
+ feishu: {
6
+ open: "https://open.feishu.cn",
7
+ accounts: "https://accounts.feishu.cn",
8
+ applink: "https://applink.feishu.cn",
9
+ docs: "https://open.feishu.cn/document",
10
+ console: "https://open.feishu.cn/app",
11
+ },
12
+ lark: {
13
+ open: "https://open.larksuite.com",
14
+ accounts: "https://accounts.larksuite.com",
15
+ applink: "https://applink.larksuite.com",
16
+ docs: "https://open.larksuite.com/document",
17
+ console: "https://open.larksuite.com/app",
18
+ },
19
+ };
20
+ export function parseLarkinTenant(value) {
21
+ return value === "feishu" || value === "lark" ? value : null;
22
+ }
23
+ export function openPlatformHost(tenant) {
24
+ return PLATFORM_HOSTS[tenant].open;
25
+ }
26
+ export function registerAppAccountsHost(tenant) {
27
+ // node-sdk registerApp.domain / larkDomain interpolate https://${host}
28
+ return new URL(PLATFORM_HOSTS[tenant].accounts).host;
29
+ }
30
+ export function isOpenPlatformHost(value) {
31
+ return value === PLATFORM_HOSTS.feishu.open || value === PLATFORM_HOSTS.lark.open;
32
+ }
33
+ export function requireOpenDomain(value) {
34
+ if (isOpenPlatformHost(value))
35
+ return value;
36
+ throw new Error("missing or invalid Open Platform domain");
37
+ }
38
+ export function accountTenantFromOpenHost(domain) {
39
+ return domain === PLATFORM_HOSTS.lark.open ? "lark" : "feishu";
40
+ }
@@ -0,0 +1,161 @@
1
+ function escapeXml(value) {
2
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3
+ }
4
+ function extractTag(content, tagName) {
5
+ const match = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`).exec(content);
6
+ return match ? match[1].trim() : null;
7
+ }
8
+ /**
9
+ * Pi subagents emit terminal <status> as Done or a display label
10
+ * (Error: ..., Aborted (...), Stopped, Wrapped up (turn limit)).
11
+ * Raw tokens completed/error/aborted/stopped/steered/turn-limit are also accepted.
12
+ */
13
+ const TERMINAL_STATUS_TOKENS = new Set([
14
+ "done",
15
+ "completed",
16
+ "error",
17
+ "aborted",
18
+ "stopped",
19
+ "steered",
20
+ "turn-limit",
21
+ "turn_limit",
22
+ ]);
23
+ export function isCanonicalTerminalPiSubagentStatus(status) {
24
+ const normalized = status.trim().toLowerCase();
25
+ if (!normalized)
26
+ return false;
27
+ if (TERMINAL_STATUS_TOKENS.has(normalized))
28
+ return true;
29
+ if (normalized.startsWith("error:") || normalized.startsWith("error "))
30
+ return true;
31
+ if (normalized.startsWith("aborted"))
32
+ return true;
33
+ if (normalized.startsWith("stopped"))
34
+ return true;
35
+ if (normalized.includes("turn limit") || normalized.includes("turn-limit") || normalized.includes("turn_limit")) {
36
+ return true;
37
+ }
38
+ if (normalized.includes("steered"))
39
+ return true;
40
+ return false;
41
+ }
42
+ function parseTaskNotificationBlock(block) {
43
+ const taskId = extractTag(block, "task-id");
44
+ const status = extractTag(block, "status");
45
+ const summary = extractTag(block, "summary");
46
+ const result = extractTag(block, "result");
47
+ if (!taskId || !status || !summary || !result)
48
+ return null;
49
+ if (!isCanonicalTerminalPiSubagentStatus(status))
50
+ return null;
51
+ return {
52
+ taskId,
53
+ toolUseId: extractTag(block, "tool-use-id") ?? undefined,
54
+ outputFile: extractTag(block, "output-file") ?? undefined,
55
+ status,
56
+ summary,
57
+ result,
58
+ };
59
+ }
60
+ function parseCanonicalNotificationContent(content) {
61
+ const blocks = [...content.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)]
62
+ .map((match) => match[1]);
63
+ if (blocks.length === 0)
64
+ return null;
65
+ const seen = new Set();
66
+ const notifications = [];
67
+ for (const block of blocks) {
68
+ const parsed = parseTaskNotificationBlock(block);
69
+ if (!parsed || seen.has(parsed.taskId))
70
+ continue;
71
+ seen.add(parsed.taskId);
72
+ notifications.push(parsed);
73
+ }
74
+ if (notifications.length === 0)
75
+ return null;
76
+ const taskIds = notifications.map((notification) => notification.taskId);
77
+ return { taskIds, notifications, content, key: taskIds.join("|") };
78
+ }
79
+ function collectCandidateContents(node, found = new Set()) {
80
+ const candidates = [];
81
+ const visit = (value) => {
82
+ if (value === null || value === undefined)
83
+ return;
84
+ if (Array.isArray(value)) {
85
+ for (const item of value)
86
+ visit(item);
87
+ return;
88
+ }
89
+ if (typeof value !== "object")
90
+ return;
91
+ const record = value;
92
+ if (record.customType === "subagent-notification" && typeof record.content === "string") {
93
+ if (!found.has(record.content)) {
94
+ found.add(record.content);
95
+ candidates.push(record.content);
96
+ }
97
+ }
98
+ else if (Array.isArray(record.content)) {
99
+ visit(record.content);
100
+ }
101
+ if (Array.isArray(record.messages))
102
+ visit(record.messages);
103
+ if (Array.isArray(record.parts))
104
+ visit(record.parts);
105
+ };
106
+ visit(node);
107
+ return candidates;
108
+ }
109
+ export function buildCanonicalPiSubagentNotificationContent(options) {
110
+ const status = options.status ?? "Done";
111
+ const summary = options.summary ?? `Agent ${JSON.stringify("Fixture")} completed`;
112
+ const result = options.result ?? "Fixture result.";
113
+ const totalTokens = options.totalTokens ?? 12;
114
+ const toolUses = options.toolUses ?? 1;
115
+ const durationMs = options.durationMs ?? 34;
116
+ return [
117
+ "<task-notification>",
118
+ `<task-id>${escapeXml(options.taskId)}</task-id>`,
119
+ options.toolUseId ? `<tool-use-id>${escapeXml(options.toolUseId)}</tool-use-id>` : null,
120
+ options.outputFile ? `<output-file>${escapeXml(options.outputFile)}</output-file>` : null,
121
+ `<status>${escapeXml(status)}</status>`,
122
+ `<summary>${escapeXml(summary)}</summary>`,
123
+ `<result>${escapeXml(result)}</result>`,
124
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
125
+ "</task-notification>",
126
+ ].filter(Boolean).join("\n");
127
+ }
128
+ export function buildCanonicalPiSubagentAssistantMessage(options) {
129
+ return {
130
+ role: "assistant",
131
+ content: [{
132
+ type: "custom",
133
+ customType: "subagent-notification",
134
+ content: buildCanonicalPiSubagentNotificationContent(options),
135
+ }],
136
+ };
137
+ }
138
+ export function extractCanonicalPiSubagentNotification(messages) {
139
+ const seen = new Set();
140
+ const notifications = [];
141
+ const contents = [];
142
+ for (const content of collectCandidateContents(messages)) {
143
+ const parsed = parseCanonicalNotificationContent(content);
144
+ if (!parsed)
145
+ continue;
146
+ contents.push(parsed.content);
147
+ for (const notification of parsed.notifications) {
148
+ if (seen.has(notification.taskId))
149
+ continue;
150
+ seen.add(notification.taskId);
151
+ notifications.push(notification);
152
+ }
153
+ }
154
+ if (notifications.length === 0)
155
+ return null;
156
+ const taskIds = notifications.map((notification) => notification.taskId);
157
+ return { taskIds, notifications, content: contents.join("\n"), key: taskIds.join("|") };
158
+ }
159
+ export function extractCanonicalPiSubagentCompletionKeyFromMessages(messages) {
160
+ return extractCanonicalPiSubagentNotification(messages)?.key ?? null;
161
+ }
@@ -10,6 +10,7 @@ import { BUNDLED_PI_VERSION, piAgentDirectory } from "./pi-provider-config.mjs";
10
10
  import { recordPiRuntimeArtifactProvenance } from "./pi-artifact-provenance.mjs";
11
11
  import { traceProcessBoundary } from "../platform/process-boundary-trace.mjs";
12
12
  import { resolvePiSubagentExtensionArg } from "./pi-subagent-injection.mjs";
13
+ import { extractCanonicalPiSubagentCompletionKeyFromMessages } from "./pi-subagents-notification.mjs";
13
14
  import { resolvePiBashTimeoutExtensionArg } from "./pi-bash-timeout-injection.mjs";
14
15
  import { classifyRuntimePrerequisite, probeNativeRuntimeReadiness, providerAuthenticationFailureReadiness, RuntimePrerequisiteError, } from "./runtime-readiness.mjs";
15
16
  import { buildStrictProviderErrorInput, classifyStrictProviderError, INTERNAL_CONTEXT_WINDOW_PROJECTION_REASON, } from "./provider-error-classifier.mjs";
@@ -451,6 +452,8 @@ class PiSession extends EventSession {
451
452
  observedSubmitEpochs = new Set();
452
453
  observedAcceptedEpochs = new Set();
453
454
  observedCompletedEpochs = new Set();
455
+ observedBackgroundCompletionKeys = new Set();
456
+ pendingUnownedCompletionKeys = new Set();
454
457
  observedAgentEndEpochs = new Set();
455
458
  firstOutputObserved = false;
456
459
  toolCallOpen = false;
@@ -551,6 +554,8 @@ class PiSession extends EventSession {
551
554
  this.observedSubmitEpochs.clear();
552
555
  this.observedAcceptedEpochs.clear();
553
556
  this.observedCompletedEpochs.clear();
557
+ this.observedBackgroundCompletionKeys.clear();
558
+ this.pendingUnownedCompletionKeys.clear();
554
559
  this.observedAgentEndEpochs.clear();
555
560
  this.activeEpoch = null;
556
561
  this.settleArmedEpoch = null;
@@ -600,11 +605,25 @@ class PiSession extends EventSession {
600
605
  this.observedCompletedEpochs.add(this.activeEpoch);
601
606
  this.emitObservation("completed");
602
607
  }
608
+ const completionNotificationKey = extractCanonicalPiSubagentCompletionKeyFromMessages(event.messages);
609
+ if (completionNotificationKey
610
+ && this.activeEpoch === null
611
+ && !this.observedBackgroundCompletionKeys.has(completionNotificationKey)) {
612
+ // agent_end can still have an active Pi session. Prompting before
613
+ // unowned agent_settled is rejected as "Agent is already processing".
614
+ this.pendingUnownedCompletionKeys.add(completionNotificationKey);
615
+ }
603
616
  }
604
617
  else if (event?.type === "agent_settled") {
605
618
  const epoch = this.activeEpoch;
606
- if (epoch === null || this.settleArmedEpoch !== epoch)
619
+ if (epoch === null) {
620
+ this.flushPendingUnownedCompletions();
621
+ return;
622
+ }
623
+ if (this.settleArmedEpoch !== epoch) {
624
+ this.flushPendingUnownedCompletions();
607
625
  return;
626
+ }
608
627
  this.activeEpoch = null;
609
628
  this.settleArmedEpoch = null;
610
629
  const error = this.finalAssistantError;
@@ -645,6 +664,7 @@ class PiSession extends EventSession {
645
664
  this.observedAcceptedEpochs.delete(epoch);
646
665
  this.observedCompletedEpochs.delete(epoch);
647
666
  this.observedAgentEndEpochs.delete(epoch);
667
+ this.flushPendingUnownedCompletions();
648
668
  }
649
669
  else if (event?.type === "tool_execution_start") {
650
670
  if (!this.firstOutputObserved) {
@@ -672,6 +692,15 @@ class PiSession extends EventSession {
672
692
  this.emit({ type: "activity", activity: kind, text: String(event.assistantMessageEvent.delta) });
673
693
  }
674
694
  }
695
+ flushPendingUnownedCompletions() {
696
+ for (const completionKey of this.pendingUnownedCompletionKeys) {
697
+ if (this.observedBackgroundCompletionKeys.has(completionKey))
698
+ continue;
699
+ this.observedBackgroundCompletionKeys.add(completionKey);
700
+ this.emitObservation("completed", { completionKey });
701
+ }
702
+ this.pendingUnownedCompletionKeys.clear();
703
+ }
675
704
  emitObservation(phase, fields = {}) {
676
705
  const inputId = this.oldestOwnedInput();
677
706
  const observation = { type: "runtime-observation", runtime: "pi",
@@ -681,6 +710,8 @@ class PiSession extends EventSession {
681
710
  Object.defineProperty(observation, "sessionId", { value: this.sessionId, enumerable: false });
682
711
  if (inputId)
683
712
  Object.defineProperty(observation, "inputId", { value: inputId, enumerable: false });
713
+ if (fields.completionKey)
714
+ Object.defineProperty(observation, "completionKey", { value: fields.completionKey, enumerable: false });
684
715
  this.emit(observation);
685
716
  }
686
717
  oldestOwnedInput() {