@hybridaione/hybridclaw 0.2.6 → 0.2.8

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.
Files changed (118) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +21 -7
  3. package/config.example.json +7 -0
  4. package/container/package-lock.json +2 -2
  5. package/container/package.json +1 -1
  6. package/container/src/approval-policy.ts +136 -34
  7. package/container/src/browser-tools.ts +241 -0
  8. package/container/src/hybridai-client.ts +31 -3
  9. package/container/src/index.ts +25 -1
  10. package/container/src/token-usage.ts +89 -10
  11. package/container/src/tools.ts +443 -38
  12. package/container/src/types.ts +18 -0
  13. package/dist/agent.d.ts.map +1 -1
  14. package/dist/agent.js +5 -2
  15. package/dist/agent.js.map +1 -1
  16. package/dist/channels/discord/delivery.d.ts.map +1 -1
  17. package/dist/channels/discord/delivery.js +5 -1
  18. package/dist/channels/discord/delivery.js.map +1 -1
  19. package/dist/channels/discord/inbound.d.ts +27 -0
  20. package/dist/channels/discord/inbound.d.ts.map +1 -1
  21. package/dist/channels/discord/inbound.js +125 -16
  22. package/dist/channels/discord/inbound.js.map +1 -1
  23. package/dist/channels/discord/prompt-adapter.d.ts +3 -0
  24. package/dist/channels/discord/prompt-adapter.d.ts.map +1 -0
  25. package/dist/channels/discord/prompt-adapter.js +26 -0
  26. package/dist/channels/discord/prompt-adapter.js.map +1 -0
  27. package/dist/channels/discord/runtime.d.ts +1 -1
  28. package/dist/channels/discord/runtime.d.ts.map +1 -1
  29. package/dist/channels/discord/runtime.js +207 -63
  30. package/dist/channels/discord/runtime.js.map +1 -1
  31. package/dist/channels/discord/send-permissions.d.ts +19 -0
  32. package/dist/channels/discord/send-permissions.d.ts.map +1 -0
  33. package/dist/channels/discord/send-permissions.js +112 -0
  34. package/dist/channels/discord/send-permissions.js.map +1 -0
  35. package/dist/channels/discord/stream.d.ts.map +1 -1
  36. package/dist/channels/discord/stream.js +3 -4
  37. package/dist/channels/discord/stream.js.map +1 -1
  38. package/dist/channels/discord/tool-actions.d.ts +7 -1
  39. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  40. package/dist/channels/discord/tool-actions.js +256 -17
  41. package/dist/channels/discord/tool-actions.js.map +1 -1
  42. package/dist/channels/prompt-adapters.d.ts +14 -0
  43. package/dist/channels/prompt-adapters.d.ts.map +1 -0
  44. package/dist/channels/prompt-adapters.js +35 -0
  45. package/dist/channels/prompt-adapters.js.map +1 -0
  46. package/dist/cli.js +30 -1
  47. package/dist/cli.js.map +1 -1
  48. package/dist/config.d.ts +7 -0
  49. package/dist/config.d.ts.map +1 -1
  50. package/dist/config.js +14 -0
  51. package/dist/config.js.map +1 -1
  52. package/dist/container-runner.d.ts.map +1 -1
  53. package/dist/container-runner.js +2 -1
  54. package/dist/container-runner.js.map +1 -1
  55. package/dist/gateway-service.d.ts.map +1 -1
  56. package/dist/gateway-service.js +63 -15
  57. package/dist/gateway-service.js.map +1 -1
  58. package/dist/gateway.js +140 -3
  59. package/dist/gateway.js.map +1 -1
  60. package/dist/health.d.ts.map +1 -1
  61. package/dist/health.js +27 -8
  62. package/dist/health.js.map +1 -1
  63. package/dist/heartbeat.d.ts.map +1 -1
  64. package/dist/heartbeat.js +14 -0
  65. package/dist/heartbeat.js.map +1 -1
  66. package/dist/hybridai-models.d.ts +8 -0
  67. package/dist/hybridai-models.d.ts.map +1 -0
  68. package/dist/hybridai-models.js +132 -0
  69. package/dist/hybridai-models.js.map +1 -0
  70. package/dist/prompt-hooks.d.ts +3 -0
  71. package/dist/prompt-hooks.d.ts.map +1 -1
  72. package/dist/prompt-hooks.js +46 -1
  73. package/dist/prompt-hooks.js.map +1 -1
  74. package/dist/runtime-config.d.ts +14 -0
  75. package/dist/runtime-config.d.ts.map +1 -1
  76. package/dist/runtime-config.js +60 -2
  77. package/dist/runtime-config.js.map +1 -1
  78. package/dist/scheduled-task-runner.d.ts.map +1 -1
  79. package/dist/scheduled-task-runner.js +14 -0
  80. package/dist/scheduled-task-runner.js.map +1 -1
  81. package/dist/types.d.ts +4 -0
  82. package/dist/types.d.ts.map +1 -1
  83. package/dist/types.js.map +1 -1
  84. package/docs/index.html +36 -1
  85. package/package.json +1 -1
  86. package/src/agent.ts +15 -1
  87. package/src/channels/discord/delivery.ts +8 -1
  88. package/src/channels/discord/inbound.ts +152 -23
  89. package/src/channels/discord/prompt-adapter.ts +34 -0
  90. package/src/channels/discord/runtime.ts +277 -75
  91. package/src/channels/discord/send-permissions.ts +196 -0
  92. package/src/channels/discord/stream.ts +12 -4
  93. package/src/channels/discord/tool-actions.ts +1036 -19
  94. package/src/channels/prompt-adapters.ts +55 -0
  95. package/src/cli.ts +33 -1
  96. package/src/config.ts +24 -0
  97. package/src/container-runner.ts +35 -6
  98. package/src/gateway-service.ts +70 -17
  99. package/src/gateway.ts +191 -6
  100. package/src/health.ts +63 -17
  101. package/src/heartbeat.ts +15 -0
  102. package/src/hybridai-models.ts +158 -0
  103. package/src/prompt-hooks.ts +52 -4
  104. package/src/runtime-config.ts +133 -6
  105. package/src/scheduled-task-runner.ts +14 -0
  106. package/src/types.ts +5 -0
  107. package/tests/approval-policy.test.ts +111 -0
  108. package/tests/channel-message-tool-hints.test.ts +64 -0
  109. package/tests/container.message-tool-normalization.test.ts +204 -0
  110. package/tests/discord.basic.test.ts +182 -1
  111. package/tests/discord.send-permissions.test.ts +171 -0
  112. package/tests/discord.tool-action-aliases.test.ts +28 -0
  113. package/tests/discord.tool-actions-channel-lookup.test.ts +166 -0
  114. package/tests/discord.tool-actions-member-lookup.test.ts +186 -0
  115. package/tests/discord.tool-actions-send.test.ts +439 -0
  116. package/tests/hybridai-client.test.ts +112 -0
  117. package/tests/hybridai-models.test.ts +46 -0
  118. package/tests/token-usage.cache.test.ts +128 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,54 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## [0.2.8](https://github.com/HybridAIOne/hybridclaw/tree/v0.2.8)
8
+
9
+ ### Added
10
+
11
+ - **Discord send policy controls**: Added runtime config for `discord.sendPolicy` (`open|allowlist|disabled`) with global/channel/guild/user/role allowlist checks for outbound sends.
12
+ - **Channel-aware prompt adapters**: Added channel-specific message-tool hint adapters (including Discord action/component guidance) injected into system prompts.
13
+ - **Expanded Discord message actions**: Added `react`, `quote-reply`, `edit`, `delete`, `pin`, `unpin`, `thread-create`, and `thread-reply` actions to the `message` tool path.
14
+ - **Message-tool regression coverage**: Added focused unit coverage for action aliases, target normalization, member/channel lookup behavior, send-policy checks, and channel hint injection.
15
+
16
+ ### Changed
17
+
18
+ - **Message-tool intent guidance**: System prompt guidance now includes explicit send/post/DM/notify triggers, send parameter guidance (`to` + message), and reply suppression token handling for tool-only sends.
19
+ - **Action alias + target normalization**: Message action normalization now supports natural aliases (`dm`, `post`, `reply`, `respond`, `history`, `fetch`, `lookup`, `whois`) and normalizes Discord prefixes/mentions.
20
+ - **Tool description enrichment**: `message` tool descriptions now emphasize natural-language intent phrases and enumerate current/other configured Discord channels with supported actions.
21
+ - **Single-call DM targeting**: `send` now resolves user targets inline (IDs, mentions, usernames/display names with guild context), including fallback via `user`/`username` when no explicit channel target is passed.
22
+ - **Discord action API flexibility**: `/api/discord/action` now accepts normalized aliases and extended send payload fields (`components`, `contextChannelId`, threading/message mutation fields).
23
+
24
+ ### Fixed
25
+
26
+ - **Structured target-resolution errors**: Member/user lookup failures now return structured JSON errors with disambiguation candidates and actionable hints.
27
+ - **Ambiguous target handling**: Added `resolveAmbiguous` support (`error|best`) to allow safe candidate return or best-match auto-resolution for member/user lookups.
28
+ - **Duplicate send-reply leakage**: Gateway chat responses now strip the message-send silent reply token and normalize final user-visible success text.
29
+
30
+ ## [0.2.7](https://github.com/HybridAIOne/hybridclaw/tree/v0.2.7)
31
+
32
+ ### Added
33
+
34
+ - **Private approval slash command**: Added `/approve` with private (ephemeral) responses for `view`, `yes`, `session`, `agent`, and `no`, including optional `approval_id`.
35
+ - **Static model context-window catalog**: Added curated context-window mappings (Claude/Gemini/GPT-5 families) plus family-aware model-id fallback matching for session status metrics without runtime model-list fetches.
36
+ - **Discord command access + output controls**: Added runtime config support for `discord.commandMode`, `discord.commandAllowedUserIds`, `discord.textChunkLimit`, and `discord.maxLinesPerMessage`.
37
+ - **HybridAI completion budget control**: Added `hybridai.maxTokens` runtime setting and request wiring (`max_tokens`) for container model calls.
38
+
39
+ ### Changed
40
+
41
+ - **Approval prompt visibility in Discord**: Channel responses now post a minimal “approval required” notice and move full approval details/decisions into private slash-command responses (`/approve`), matching the visibility pattern of `/status`.
42
+ - **Discord command handler context**: Command execution now receives invoking `userId` and `username` so approval actions can be scoped to the requesting user.
43
+ - **Discord slash command discoverability**: `/status` and `/approve` are now upserted globally for DM visibility while guild-only authorization checks remain enforced in servers.
44
+ - **Discord free-mode message relevance gating**: Free-mode replies now skip low-signal acknowledgements/URL-only chatter and avoid jumping in when other users are explicitly mentioned.
45
+ - **Status context usage reporting**: Session status now derives context usage from usage telemetry and static model context-window resolution instead of char-budget estimation only.
46
+ - **Approval parsing and trust scoping**: Approval response parsing now handles mention-prefixed/batched messages, and network trust scopes now normalize hosts to broader domain scopes.
47
+ - **Prompt dump diagnostics**: `data/last_prompt.jsonl` now includes media context plus allowed/blocked tool lists for richer debugging context.
48
+
49
+ ### Fixed
50
+
51
+ - **Google Images/Lens upload compatibility**: `browser_upload` now supports CSS-selector targets and automatically falls back from wrapper refs to detected `input[type="file"]` selectors when upload fails with non-input elements.
52
+ - **Install-root container bootstrap**: CLI container readiness checks now resolve the package install root, preventing false build failures when invoked from non-package working directories.
53
+ - **DM slash command registration regression**: Restored reliable discovery/usage of HybridClaw slash commands in Discord DMs.
54
+
7
55
  ## [0.2.6](https://github.com/HybridAIOne/hybridclaw/tree/v0.2.6)
8
56
 
9
57
  ### Added
package/README.md CHANGED
@@ -11,7 +11,16 @@ npm install -g @hybridaione/hybridclaw
11
11
  hybridclaw onboarding
12
12
  ```
13
13
 
14
- Latest release: [v0.2.6](https://github.com/HybridAIOne/hybridclaw/releases/tag/v0.2.6)
14
+ Latest release: [v0.2.8](https://github.com/HybridAIOne/hybridclaw/releases/tag/v0.2.8)
15
+
16
+ ## Release highlights (v0.2.8)
17
+
18
+ - Discord `message` tool now supports richer actions (`send`, `read`, `member-info`, `channel-info`, `react`, `quote-reply`, `edit`, `delete`, `pin`, `unpin`, `thread-create`, `thread-reply`).
19
+ - Send intent handling is more natural: aliases like `dm`, `post`, `reply`, `respond`, `history`, `fetch`, `lookup`, and `whois` normalize automatically.
20
+ - Single-call DM routing is now supported for user targets (`user`/`username`, `@mentions`, IDs), including inline resolution when no explicit `channelId` is provided.
21
+ - Message-tool descriptions now include intent phrases and enumerate configured channels so isolated/cron runs can discover available Discord targets.
22
+ - Discord send governance gained runtime allowlist controls via `discord.sendPolicy` plus channel/guild/user/role checks.
23
+ - Lookup failures now return structured errors with disambiguation candidates and optional `resolveAmbiguous=best` auto-pick behavior.
15
24
 
16
25
  ## HybridAI Advantage
17
26
 
@@ -32,7 +41,7 @@ Latest release: [v0.2.6](https://github.com/HybridAIOne/hybridclaw/releases/tag/
32
41
  ## Quick start
33
42
 
34
43
  ```bash
35
- # Install dependencies (this also installs container deps via postinstall)
44
+ # Install dependencies
36
45
  npm install
37
46
 
38
47
  # Run onboarding (also auto-runs on first `gateway`/`tui` start if API key is missing)
@@ -100,10 +109,14 @@ HybridClaw uses typed runtime config in `config.json` (auto-created on first run
100
109
  - `discord.guildMembersIntent` enables richer guild member context and better `@name` mention resolution in replies (requires enabling **Server Members Intent** in Discord Developer Portal)
101
110
  - `discord.presenceIntent` enables Discord presence events (requires enabling **Presence Intent** in Discord Developer Portal)
102
111
  - `discord.respondToAllMessages` is a global fallback for open-policy guild channels without explicit mode config (`false` mention-gated, `true` free-response)
103
- - `discord.commandUserId` restricts `!claw <command>` admin commands to a single Discord user ID (all other messages still use normal chat handling)
104
- - `discord.commandsOnly` optional hard mode: if `true`, the bot ignores non-`!claw` messages and only accepts prefixed commands (optionally limited by `discord.commandUserId`)
105
- - `discord.groupPolicy` controls guild channel scope: `open` (default), `allowlist`, or `disabled`
112
+ - `discord.commandMode` controls command access: `public` (any user can run slash/`!claw` commands) or `restricted` (only allowlisted users can run slash/`!claw` commands)
113
+ - `discord.commandAllowedUserIds` is the allowlist used when `discord.commandMode` is `restricted`
114
+ - `discord.commandUserId` is a legacy single-user allowlist alias; when set without `commandMode`, runtime treats command access as `restricted` for backward compatibility
115
+ - `discord.commandsOnly` optional hard mode: if `true`, the bot ignores non-`!claw` messages and only accepts prefixed commands (still subject to `discord.commandMode`)
116
+ - `discord.groupPolicy` controls guild channel scope: `open` (default, mention-first unless a channel is set to `free`), `allowlist`, or `disabled`
106
117
  - `discord.freeResponseChannels` is a Hermes-style channel ID list that gets free-response behavior while other channels remain mention-gated
118
+ - `discord.textChunkLimit` controls Discord message chunk size (default `2000`)
119
+ - `discord.maxLinesPerMessage` controls max lines per Discord chunk (default `17`)
107
120
  - `discord.humanDelay` controls natural delays between multi-part messages (`off|natural|custom`)
108
121
  - `discord.typingMode` controls typing indicator lifecycle (`instant|thinking|streaming|never`)
109
122
  - `discord.presence.*` enables dynamic self-presence health states (healthy/degraded/exhausted mapped to `online|idle|dnd`, plus maintenance `invisible` during shutdown)
@@ -124,7 +137,7 @@ HybridClaw uses typed runtime config in `config.json` (auto-created on first run
124
137
  - `sessionCompaction.tokenBudget` and `sessionCompaction.budgetRatio` tune compaction token budgeting behavior
125
138
  - Built-in Discord humanization behaviors include night/weekend pacing, post-exchange cooldown scaling (after 5+ exchanges, reset after 20 minutes idle), selective silence in active free-mode channels, short-ack read reactions, and reconnect staggered dequeue
126
139
  - Per-guild/per-channel mode takes precedence over `discord.respondToAllMessages`
127
- - Discord slash commands: `/status`, `/channel-mode <off|mention|free>`, and `/channel-policy <open|allowlist|disabled>` (ephemeral replies)
140
+ - Discord slash commands: `/status`, `/approve [view|yes|session|agent|no] [approval_id]`, `/channel-mode <off|mention|free>`, and `/channel-policy <open|allowlist|disabled>` (ephemeral replies)
128
141
  - `skills.extraDirs` adds additional enterprise/shared skill roots (lowest precedence tier)
129
142
  - `proactive.*` controls autonomous behavior (`activeHours`, `delegation`, `autoRetry`, `ralph`)
130
143
  - `proactive.ralph.maxIterations` enables Ralph loop (`0` off, `-1` unlimited, `>0` extra autonomous iterations before forcing completion)
@@ -132,6 +145,7 @@ HybridClaw uses typed runtime config in `config.json` (auto-created on first run
132
145
  - `observability.*` controls push ingest into HybridAI (`events:batch` endpoint, batching, identity metadata)
133
146
  - Some settings require restart to fully apply (for example HTTP bind host/port)
134
147
  - Default bot is configured via `hybridai.defaultChatbotId` in `config.json`
148
+ - `hybridai.maxTokens` sets the default completion budget per model call (default `4096`)
135
149
 
136
150
  Secrets remain in `.env`:
137
151
 
@@ -328,7 +342,7 @@ The agent has access to these sandboxed tools inside the container:
328
342
  - `session_search` — search/summarize historical sessions from transcript archives
329
343
  - `delegate` — push-based background subagent tasks (`single`, `parallel`, `chain`) with auto-announced completion (no polling)
330
344
  - `web_fetch` — plain HTTP fetch + extraction for static/read-only content (docs, articles, READMEs, JSON/text APIs, direct files)
331
- - `browser_*` (optional) — full browser automation for JS-rendered or interactive pages (`navigate`, `snapshot`, `click`, `type`, `press`, `scroll`, `back`, `screenshot`, `pdf`, `close`)
345
+ - `browser_*` (optional) — full browser automation for JS-rendered or interactive pages (`navigate`, `snapshot`, `click`, `type`, `upload`, `press`, `scroll`, `back`, `screenshot`, `pdf`, `close`)
332
346
 
333
347
  `delegate` mode examples:
334
348
 
@@ -15,9 +15,15 @@
15
15
  "presenceIntent": false,
16
16
  "respondToAllMessages": false,
17
17
  "commandsOnly": false,
18
+ "commandMode": "public",
19
+ "commandAllowedUserIds": [],
18
20
  "commandUserId": "",
19
21
  "groupPolicy": "open",
22
+ "sendPolicy": "open",
23
+ "sendAllowedChannelIds": [],
20
24
  "freeResponseChannels": [],
25
+ "textChunkLimit": 2000,
26
+ "maxLinesPerMessage": 17,
21
27
  "humanDelay": {
22
28
  "mode": "natural",
23
29
  "minMs": 800,
@@ -58,6 +64,7 @@
58
64
  "baseUrl": "https://hybridai.one",
59
65
  "defaultModel": "gpt-5-nano",
60
66
  "defaultChatbotId": "",
67
+ "maxTokens": 4096,
61
68
  "enableRag": true,
62
69
  "models": ["gpt-5-nano", "gpt-5-mini", "gpt-5"]
63
70
  },
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "hybridclaw-agent",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "hybridclaw-agent",
9
- "version": "0.2.6",
9
+ "version": "0.2.8",
10
10
  "dependencies": {
11
11
  "@mozilla/readability": "^0.6.0",
12
12
  "agent-browser": "^0.15.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hybridclaw-agent",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "tsc",
@@ -437,7 +437,8 @@ function latestUserMessageText(messages: ChatMessage[]): string {
437
437
  for (let i = messages.length - 1; i >= 0; i -= 1) {
438
438
  if (messages[i].role !== 'user') continue;
439
439
  const content = messages[i].content;
440
- if (typeof content === 'string') return normalizePrompt(content);
440
+ if (typeof content === 'string')
441
+ return content.trim().slice(0, MAX_PROMPT_CHARS);
441
442
  if (!Array.isArray(content)) continue;
442
443
  const textParts: string[] = [];
443
444
  for (const part of content) {
@@ -448,7 +449,7 @@ function latestUserMessageText(messages: ChatMessage[]): string {
448
449
  if (trimmed) textParts.push(trimmed);
449
450
  }
450
451
  if (textParts.length > 0) {
451
- return normalizePrompt(textParts.join('\n'));
452
+ return textParts.join('\n').trim().slice(0, MAX_PROMPT_CHARS);
452
453
  }
453
454
  }
454
455
  return '';
@@ -474,6 +475,44 @@ function extractHostsFromUrlLikeText(input: string): string[] {
474
475
  return [...hosts];
475
476
  }
476
477
 
478
+ function normalizeHostScope(host: string): string {
479
+ const normalized = host.trim().toLowerCase().replace(/\.$/, '');
480
+ if (!normalized) return 'unknown-host';
481
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(normalized)) return normalized;
482
+ if (normalized.includes(':')) return normalized; // IPv6/host:port fragments
483
+
484
+ const labels = normalized.split('.').filter(Boolean);
485
+ if (labels.length <= 2) return normalized;
486
+
487
+ const secondLevel = labels[labels.length - 2];
488
+ const topLevel = labels[labels.length - 1];
489
+ const commonSecondLevelTlds = new Set([
490
+ 'ac',
491
+ 'co',
492
+ 'com',
493
+ 'edu',
494
+ 'gov',
495
+ 'net',
496
+ 'org',
497
+ ]);
498
+ if (
499
+ topLevel.length === 2 &&
500
+ commonSecondLevelTlds.has(secondLevel) &&
501
+ labels.length >= 3
502
+ ) {
503
+ return labels.slice(-3).join('.');
504
+ }
505
+ return labels.slice(-2).join('.');
506
+ }
507
+
508
+ function extractHostScopes(hosts: string[]): string[] {
509
+ const scopes = new Set<string>();
510
+ for (const host of hosts) {
511
+ scopes.add(normalizeHostScope(host));
512
+ }
513
+ return [...scopes];
514
+ }
515
+
477
516
  function extractAbsolutePaths(input: string): string[] {
478
517
  const paths = new Set<string>();
479
518
  for (const match of input.matchAll(ABS_PATH_RE)) {
@@ -500,39 +539,91 @@ function parseModeFromApproveMatch(
500
539
  return 'once';
501
540
  }
502
541
 
503
- function parseApprovalUserResponse(input: string): {
542
+ function parseApprovalDirective(input: string): {
504
543
  kind: 'approve' | 'deny';
505
544
  mode?: 'once' | 'session' | 'agent';
506
545
  requestId: string;
507
546
  } | null {
508
- const menuMatch = input.match(MENU_SELECTION_RE);
509
- if (menuMatch) {
510
- const requestId = String(menuMatch[2] || '').trim();
511
- const selection = menuMatch[1];
512
- if (selection === '1') return { kind: 'approve', mode: 'once', requestId };
513
- if (selection === '2')
514
- return { kind: 'approve', mode: 'session', requestId };
515
- if (selection === '3') return { kind: 'approve', mode: 'agent', requestId };
516
- return { kind: 'deny', requestId };
547
+ const normalized = input.trim();
548
+ if (!normalized) return null;
549
+
550
+ const directiveCandidates = [
551
+ normalized,
552
+ normalized.replace(/^(?:<@!?\d+>\s*)+/, ''),
553
+ ];
554
+
555
+ for (const candidate of directiveCandidates) {
556
+ if (!candidate) continue;
557
+ const menuMatch = candidate.match(MENU_SELECTION_RE);
558
+ if (menuMatch) {
559
+ const requestId = String(menuMatch[2] || '').trim();
560
+ const selection = menuMatch[1];
561
+ if (selection === '1')
562
+ return { kind: 'approve', mode: 'once', requestId };
563
+ if (selection === '2')
564
+ return { kind: 'approve', mode: 'session', requestId };
565
+ if (selection === '3')
566
+ return { kind: 'approve', mode: 'agent', requestId };
567
+ return { kind: 'deny', requestId };
568
+ }
569
+
570
+ const approveMatch = candidate.match(APPROVE_RE);
571
+ if (approveMatch) {
572
+ return {
573
+ kind: 'approve',
574
+ mode: parseModeFromApproveMatch(approveMatch),
575
+ requestId: String(approveMatch[1] || '').trim(),
576
+ };
577
+ }
578
+
579
+ const denyMatch = candidate.match(DENY_RE);
580
+ if (denyMatch) {
581
+ return {
582
+ kind: 'deny',
583
+ requestId: String(denyMatch[1] || '').trim(),
584
+ };
585
+ }
517
586
  }
518
587
 
519
- const approveMatch = input.match(APPROVE_RE);
520
- if (approveMatch) {
521
- return {
522
- kind: 'approve',
523
- mode: parseModeFromApproveMatch(approveMatch),
524
- requestId: String(approveMatch[1] || '').trim(),
525
- };
588
+ return null;
589
+ }
590
+
591
+ function parseApprovalUserResponse(input: string): {
592
+ kind: 'approve' | 'deny';
593
+ mode?: 'once' | 'session' | 'agent';
594
+ requestId: string;
595
+ } | null {
596
+ const normalized = input.trim();
597
+ if (!normalized) return null;
598
+
599
+ const candidates: string[] = [];
600
+ const pushCandidate = (value: string): void => {
601
+ const trimmed = value.trim();
602
+ if (!trimmed) return;
603
+ if (candidates.includes(trimmed)) return;
604
+ candidates.push(trimmed);
605
+ };
606
+
607
+ pushCandidate(normalized);
608
+ pushCandidate(normalized.replace(/^(?:<@!?\d+>\s*)+/, ''));
609
+
610
+ const batchTailMatch = normalized.match(/Message\s+\d+\s*:\s*([\s\S]+)$/i);
611
+ if (batchTailMatch?.[1]) {
612
+ pushCandidate(batchTailMatch[1]);
526
613
  }
527
614
 
528
- const denyMatch = input.match(DENY_RE);
529
- if (denyMatch) {
530
- return {
531
- kind: 'deny',
532
- requestId: String(denyMatch[1] || '').trim(),
533
- };
615
+ const lines = normalized
616
+ .split(/\r?\n/)
617
+ .map((line) => line.trim())
618
+ .filter(Boolean);
619
+ if (lines.length > 0) {
620
+ pushCandidate(lines[lines.length - 1]);
534
621
  }
535
622
 
623
+ for (const candidate of candidates) {
624
+ const parsed = parseApprovalDirective(candidate);
625
+ if (parsed) return parsed;
626
+ }
536
627
  return null;
537
628
  }
538
629
 
@@ -879,16 +970,25 @@ export class TrustedCoworkerApprovalRuntime {
879
970
  const requestLabel = evaluation.requestId
880
971
  ? `Approval ID: ${evaluation.requestId}`
881
972
  : '';
882
- const trustHint = evaluation.pinned
883
- ? 'This action is pinned sensitive, so session/agent trust is disabled.'
884
- : 'Reply `yes for session` (or `2`) to trust this action for this session, or `yes for agent` (or `3`) to trust it for this agent.';
973
+ const optionLines = evaluation.pinned
974
+ ? [
975
+ 'Reply `yes` (or `1`) to approve once.',
976
+ 'Reply `yes for session` (or `2`) is unavailable for pinned-sensitive actions.',
977
+ 'Reply `yes for agent` (or `3`) is unavailable for pinned-sensitive actions.',
978
+ 'Reply `no` (or `4`) to deny.',
979
+ ]
980
+ : [
981
+ 'Reply `yes` (or `1`) to approve once.',
982
+ 'Reply `yes for session` (or `2`) to trust this action for this session.',
983
+ 'Reply `yes for agent` (or `3`) to trust it for this agent.',
984
+ 'Reply `no` (or `4`) to deny.',
985
+ ];
885
986
  return [
886
987
  `I need your approval before I ${evaluation.intent.toLowerCase()}.`,
887
988
  `Why: ${evaluation.reason}`,
888
989
  `If you skip this, ${evaluation.consequenceIfDenied.charAt(0).toLowerCase()}${evaluation.consequenceIfDenied.slice(1)}`,
889
990
  requestLabel,
890
- `Reply \`yes\` (or \`1\`) to approve once, or \`no\` (or \`4\`) to deny.`,
891
- trustHint,
991
+ ...optionLines,
892
992
  `Approval expires in ${expiresIn}s.`,
893
993
  ]
894
994
  .filter(Boolean)
@@ -1023,9 +1123,11 @@ export class TrustedCoworkerApprovalRuntime {
1023
1123
 
1024
1124
  if (lowerTool === 'web_fetch' || lowerTool === 'browser_navigate') {
1025
1125
  const rawUrl = normalizeText(args.url);
1026
- const hosts = extractHostsFromUrlLikeText(rawUrl);
1027
- const primaryHost = hosts[0] || 'unknown-host';
1028
- const unseen = hosts.filter((host) => !this.seenNetworkHosts.has(host));
1126
+ const hostScopes = extractHostScopes(extractHostsFromUrlLikeText(rawUrl));
1127
+ const primaryHost = hostScopes[0] || 'unknown-host';
1128
+ const unseen = hostScopes.filter(
1129
+ (host) => !this.seenNetworkHosts.has(host),
1130
+ );
1029
1131
  return {
1030
1132
  tier: unseen.length > 0 ? 'red' : 'yellow',
1031
1133
  actionKey: `network:${primaryHost}`,
@@ -1038,7 +1140,7 @@ export class TrustedCoworkerApprovalRuntime {
1038
1140
  : 'this is an external network action',
1039
1141
  commandPreview: normalizePreview(rawUrl),
1040
1142
  pathHints: [],
1041
- hostHints: hosts,
1143
+ hostHints: hostScopes,
1042
1144
  writeIntent: false,
1043
1145
  promotableRed: unseen.length > 0,
1044
1146
  stickyYellow: true,