@xelauvas/xela-cli 0.3.1 → 0.4.0

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 (137) hide show
  1. package/bin/xela.js +0 -12
  2. package/local/README.md +112 -0
  3. package/local/start-qwen.sh +47 -0
  4. package/local/start-tunnel.sh +36 -0
  5. package/local/xela-local.sh +139 -0
  6. package/package.json +9 -5
  7. package/run.sh +33 -0
  8. package/src/Tool.ts +1 -1
  9. package/src/_shims/_generated_stubs/_universal_stub.mjs +168 -0
  10. package/src/_shims/_generated_stubs/stub_346f96741420f62caece15b196e233c6.mjs +19 -0
  11. package/src/_shims/_generated_stubs/stub_4b79af2a6ac1af5a53a55fd666b2bbb4.mjs +3 -0
  12. package/src/_shims/_generated_stubs/stub_94345ddeadacc184ff895597ec5abf71.mjs +6 -0
  13. package/src/_shims/_generated_stubs/stub_99fa0bc272643c549d8a2ec6d578436f.mjs +3 -0
  14. package/src/_shims/_generated_stubs/stub_e3451b131fcaf272a2a75270b9a427a3.mjs +9 -0
  15. package/src/_shims/_generated_stubs/text_stub.mjs +1 -0
  16. package/src/_shims/bun_bundle.js +79 -4
  17. package/src/_shims/cjs_stub.cjs +32 -10
  18. package/src/_shims/loader.js +104 -5
  19. package/src/_shims/openai_adapter.ts +446 -230
  20. package/src/_shims/register.js +16 -3
  21. package/src/assistant/gate.ts +15 -0
  22. package/src/assistant/index.ts +89 -0
  23. package/src/bootstrap/state.ts +6 -1
  24. package/src/bridge/peerSessions.ts +18 -0
  25. package/src/cli/print.ts +5 -0
  26. package/src/commands/assistant/assistant.ts +17 -0
  27. package/src/commands/assistant/index.ts +11 -0
  28. package/src/commands/brief.ts +3 -1
  29. package/src/commands/buddy/buddy.ts +88 -0
  30. package/src/commands/buddy/index.ts +12 -0
  31. package/src/commands/model/model.tsx +8 -23
  32. package/src/commands/peers/index.ts +11 -0
  33. package/src/commands/peers/peers.ts +24 -0
  34. package/src/commands/proactive/proactive.ts +64 -0
  35. package/src/commands/proactive.ts +12 -0
  36. package/src/commands/workflows/index.ts +12 -0
  37. package/src/commands/workflows/workflows.ts +36 -0
  38. package/src/commands.ts +4 -8
  39. package/src/components/Messages.tsx +1 -1
  40. package/src/components/ModelPicker.tsx +4 -34
  41. package/src/components/Spinner.tsx +54 -1
  42. package/src/components/StructuredDiff/colorDiff.ts +3 -1
  43. package/src/components/messages/SnipBoundaryMessage.tsx +15 -0
  44. package/src/components/messages/UserCrossSessionMessage.tsx +26 -0
  45. package/src/components/permissions/PermissionRequest.tsx +4 -2
  46. package/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx +96 -0
  47. package/src/constants/prompts.ts +26 -0
  48. package/src/ink/terminal.ts +9 -1
  49. package/src/jobs/classifier.ts +103 -0
  50. package/src/memdir/paths.ts +13 -1
  51. package/src/proactive/index.ts +158 -0
  52. package/src/proactive/useProactive.ts +78 -0
  53. package/src/screens/REPL.tsx +28 -8
  54. package/src/services/api/claude.ts +6 -1
  55. package/src/services/compact/autoCompact.ts +18 -4
  56. package/src/services/compact/cachedMCConfig.ts +1 -0
  57. package/src/services/compact/cachedMicrocompact.ts +113 -0
  58. package/src/services/compact/compact.ts +22 -2
  59. package/src/services/compact/reactiveCompact.ts +251 -0
  60. package/src/services/compact/snipCompact.ts +282 -0
  61. package/src/services/compact/snipProjection.ts +50 -0
  62. package/src/services/contextCollapse/index.ts +322 -0
  63. package/src/services/contextCollapse/operations.ts +187 -0
  64. package/src/services/contextCollapse/persist.ts +40 -0
  65. package/src/services/extractMemories/extractMemories.ts +11 -2
  66. package/src/services/skillSearch/featureCheck.ts +12 -0
  67. package/src/services/skillSearch/localSearch.ts +98 -0
  68. package/src/services/skillSearch/prefetch.ts +125 -0
  69. package/src/services/skillSearch/remoteSkillLoader.ts +9 -0
  70. package/src/services/skillSearch/remoteSkillState.ts +21 -0
  71. package/src/services/skillSearch/signals.ts +7 -0
  72. package/src/services/skillSearch/telemetry.ts +9 -0
  73. package/src/setup.ts +10 -5
  74. package/src/skills/bundled/dream.ts +31 -0
  75. package/src/skills/bundled/hunter.ts +45 -0
  76. package/src/skills/bundled/index.ts +10 -23
  77. package/src/skills/bundled/runSkillGenerator.ts +61 -0
  78. package/src/skills/mcpSkills.ts +101 -0
  79. package/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts +301 -0
  80. package/src/tasks/MonitorMcpTask/MonitorMcpTask.ts +273 -0
  81. package/src/tasks.ts +22 -8
  82. package/src/tools/AgentTool/runAgent.ts +11 -0
  83. package/src/tools/BriefTool/BriefTool.ts +2 -0
  84. package/src/tools/CtxInspectTool/CtxInspectTool.ts +102 -0
  85. package/src/tools/DiscoverSkillsTool/DiscoverSkillsTool.ts +78 -0
  86. package/src/tools/DiscoverSkillsTool/prompt.ts +3 -0
  87. package/src/tools/ListPeersTool/ListPeersTool.ts +89 -0
  88. package/src/tools/MCPTool/MCPTool.ts +6 -0
  89. package/src/tools/MonitorTool/MonitorTool.ts +273 -0
  90. package/src/tools/MonitorTool/UI.tsx +25 -0
  91. package/src/tools/MonitorTool/events.ts +84 -0
  92. package/src/tools/MonitorTool/prompt.ts +45 -0
  93. package/src/tools/OverflowTestTool/OverflowTestTool.ts +73 -0
  94. package/src/tools/PushNotificationTool/PushNotificationTool.ts +119 -0
  95. package/src/tools/ReviewArtifactTool/ReviewArtifactTool.ts +93 -0
  96. package/src/tools/ReviewArtifactTool/prompt.ts +5 -0
  97. package/src/tools/SendUserFileTool/SendUserFileTool.ts +87 -0
  98. package/src/tools/SendUserFileTool/prompt.ts +7 -0
  99. package/src/tools/SleepTool/SleepTool.ts +126 -0
  100. package/src/tools/SnipTool/SnipTool.ts +90 -0
  101. package/src/tools/SnipTool/prompt.ts +5 -0
  102. package/src/tools/SubscribePRTool/SubscribePRTool.ts +115 -0
  103. package/src/tools/TerminalCaptureTool/TerminalCaptureTool.ts +129 -0
  104. package/src/tools/TerminalCaptureTool/prompt.ts +8 -0
  105. package/src/tools/ToolSearchTool/prompt.ts +1 -1
  106. package/src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts +99 -0
  107. package/src/tools/WebBrowserTool/WebBrowserPanel.tsx +10 -0
  108. package/src/tools/WebBrowserTool/WebBrowserTool.ts +190 -0
  109. package/src/tools/WebBrowserTool/prompt.ts +16 -0
  110. package/src/tools/WorkflowTool/UI.tsx +27 -0
  111. package/src/tools/WorkflowTool/WorkflowTool.ts +401 -0
  112. package/src/tools/WorkflowTool/bundled/index.ts +88 -0
  113. package/src/tools/WorkflowTool/constants.ts +7 -0
  114. package/src/tools/WorkflowTool/createWorkflowCommand.ts +84 -0
  115. package/src/tools/WorkflowTool/prompt.ts +38 -0
  116. package/src/tools/WorkflowTool/runtime.ts +331 -0
  117. package/src/tools.ts +36 -31
  118. package/src/utils/betas.ts +5 -0
  119. package/src/utils/context.ts +9 -0
  120. package/src/utils/conversationRecovery.ts +1 -1
  121. package/src/utils/forkedAgent.ts +4 -0
  122. package/src/utils/logoV2Utils.ts +7 -3
  123. package/src/utils/messageQueueManager.ts +23 -9
  124. package/src/utils/model/model.ts +10 -9
  125. package/src/utils/model/modelOptions.ts +0 -21
  126. package/src/utils/model/validateModel.ts +0 -19
  127. package/src/utils/permissions/permissionSetup.ts +5 -0
  128. package/src/utils/permissions/permissions.ts +25 -0
  129. package/src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt +38 -0
  130. package/src/utils/permissions/yolo-classifier-prompts/permissions_external.txt +25 -0
  131. package/src/utils/permissions/yoloClassifier.ts +28 -6
  132. package/src/utils/plugins/officialMarketplaceStartupCheck.ts +9 -0
  133. package/src/utils/systemThemeWatcher.ts +62 -0
  134. package/src/utils/udsClient.ts +115 -0
  135. package/src/utils/udsMessaging.ts +157 -0
  136. package/src/utils/yaml.ts +251 -5
  137. package/src/utils/model/providerModels.ts +0 -168
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod/v4'
2
+ import {
3
+ getStore,
4
+ renderMessageForSummary,
5
+ } from '../../services/contextCollapse/operations.js'
6
+ import { buildTool } from '../../Tool.js'
7
+ import { lazySchema } from '../../utils/lazySchema.js'
8
+
9
+ export const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
10
+
11
+ const DESCRIPTION = `Look inside the context window's collapsed spans. Earlier parts of this conversation may have been replaced by <collapsed id="..."> summaries to save space. Call with no id to list the collapsed spans (id, size, first line of the summary); call with an id to read that span's full summary, or with id and detail "messages" to read the original messages verbatim (large — only when the summary is not enough). Also lists spans that are staged (summarized, not yet swapped in).`
12
+
13
+ const inputSchema = lazySchema(() =>
14
+ z.strictObject({
15
+ id: z.string().optional().describe('Collapse id from a <collapsed id="..."> placeholder'),
16
+ detail: z.enum(['summary', 'messages']).optional().describe('What to return for an id (default summary)'),
17
+ limit: z.number().int().positive().max(200_000).optional().describe('Max characters of original messages to return (default 30000)'),
18
+ }),
19
+ )
20
+ type InputSchema = ReturnType<typeof inputSchema>
21
+
22
+ const outputSchema = lazySchema(() =>
23
+ z.object({
24
+ text: z.string(),
25
+ }),
26
+ )
27
+ type OutputSchema = ReturnType<typeof outputSchema>
28
+ export type Output = z.infer<OutputSchema>
29
+
30
+ export const CtxInspectTool = buildTool({
31
+ name: CTX_INSPECT_TOOL_NAME,
32
+ searchHint: 'read summaries or originals of collapsed conversation spans',
33
+ maxResultSizeChars: 250_000,
34
+ userFacingName: () => 'Context inspect',
35
+ get inputSchema(): InputSchema {
36
+ return inputSchema()
37
+ },
38
+ get outputSchema(): OutputSchema {
39
+ return outputSchema()
40
+ },
41
+ isConcurrencySafe() {
42
+ return true
43
+ },
44
+ isReadOnly() {
45
+ return true
46
+ },
47
+ async checkPermissions(input) {
48
+ return { behavior: 'allow', updatedInput: input }
49
+ },
50
+ async description({ id }) {
51
+ return id ? `Inspect collapsed span ${id}` : 'List collapsed spans'
52
+ },
53
+ async prompt() {
54
+ return DESCRIPTION
55
+ },
56
+ mapToolResultToToolResultBlockParam(output, toolUseID) {
57
+ return { tool_use_id: toolUseID, type: 'tool_result', content: output.text }
58
+ },
59
+ renderToolUseMessage(input: { id?: string; detail?: string }) {
60
+ return input.id ? `${input.id}${input.detail === 'messages' ? ' (messages)' : ''}` : 'list'
61
+ },
62
+ renderToolResultMessage(output: Output) {
63
+ return output.text.split('\n')[0]?.slice(0, 100) ?? ''
64
+ },
65
+ async call({ id, detail, limit }) {
66
+ const store = getStore()
67
+ if (!id) {
68
+ const lines: string[] = []
69
+ if (store.commits.length === 0 && store.staged.length === 0) {
70
+ lines.push('No collapsed spans yet. The conversation is still fully in context.')
71
+ }
72
+ if (store.commits.length > 0) {
73
+ lines.push(`Collapsed spans (${store.commits.length}):`)
74
+ for (const c of store.commits) {
75
+ const first = c.summary.split('\n').find(l => l.trim()) ?? ''
76
+ lines.push(`- id ${c.collapseId}: ${c.archived.length} messages — ${first.slice(0, 140)}`)
77
+ }
78
+ }
79
+ if (store.staged.length > 0) {
80
+ lines.push(`Staged (summarized, not yet swapped in): ${store.staged.length}`)
81
+ for (const s of store.staged) {
82
+ lines.push(`- ${s.startUuid.slice(0, 8)}..${s.endUuid.slice(0, 8)}: ${s.summary.split('\n')[0]?.slice(0, 120) ?? ''}`)
83
+ }
84
+ }
85
+ return { data: { text: lines.join('\n') } }
86
+ }
87
+ const commit = store.commits.find(c => c.collapseId === id || c.collapseId === id.padStart(16, '0'))
88
+ if (!commit) {
89
+ return { data: { text: `No collapsed span with id ${id}. Call without an id to list them.` } }
90
+ }
91
+ if (detail === 'messages') {
92
+ if (commit.archived.length === 0) {
93
+ return { data: { text: `The original messages of span ${id} are not loaded in this session (they live in the transcript). Summary:\n\n${commit.summary}` } }
94
+ }
95
+ const max = limit ?? 30_000
96
+ let text = commit.archived.map(renderMessageForSummary).join('\n\n')
97
+ if (text.length > max) text = text.slice(0, max) + `\n… (${text.length - max} more characters)`
98
+ return { data: { text: `Original messages of span ${id} (${commit.archived.length}):\n\n${text}` } }
99
+ }
100
+ return { data: { text: `Summary of span ${id} (${commit.archived.length} messages):\n\n${commit.summary}` } }
101
+ },
102
+ })
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod/v4'
2
+ import type { Command } from '../../commands.js'
3
+ import { searchSkills } from '../../services/skillSearch/localSearch.js'
4
+ import { buildTool } from '../../Tool.js'
5
+ import { lazySchema } from '../../utils/lazySchema.js'
6
+ import { DESCRIPTION, DISCOVER_SKILLS_TOOL_NAME } from './prompt.js'
7
+
8
+ const inputSchema = lazySchema(() =>
9
+ z.strictObject({
10
+ description: z.string().describe('What you are about to do, specifically'),
11
+ limit: z.number().int().positive().max(10).optional().describe('Max results (default 5)'),
12
+ }),
13
+ )
14
+ type InputSchema = ReturnType<typeof inputSchema>
15
+
16
+ const outputSchema = lazySchema(() =>
17
+ z.object({
18
+ skills: z.array(z.object({ name: z.string(), description: z.string() })),
19
+ message: z.string(),
20
+ }),
21
+ )
22
+ type OutputSchema = ReturnType<typeof outputSchema>
23
+ export type Output = z.infer<OutputSchema>
24
+
25
+ export const DiscoverSkillsTool = buildTool({
26
+ name: DISCOVER_SKILLS_TOOL_NAME,
27
+ searchHint: 'find installed skills relevant to a task',
28
+ maxResultSizeChars: 20_000,
29
+ userFacingName: () => 'Discover skills',
30
+ get inputSchema(): InputSchema {
31
+ return inputSchema()
32
+ },
33
+ get outputSchema(): OutputSchema {
34
+ return outputSchema()
35
+ },
36
+ isConcurrencySafe() {
37
+ return true
38
+ },
39
+ isReadOnly() {
40
+ return true
41
+ },
42
+ async checkPermissions(input) {
43
+ return { behavior: 'allow', updatedInput: input }
44
+ },
45
+ async description({ description }) {
46
+ return `Find skills for: ${description.slice(0, 60)}`
47
+ },
48
+ async prompt() {
49
+ return DESCRIPTION
50
+ },
51
+ mapToolResultToToolResultBlockParam(output, toolUseID) {
52
+ return {
53
+ tool_use_id: toolUseID,
54
+ type: 'tool_result',
55
+ content:
56
+ output.skills.length === 0
57
+ ? output.message
58
+ : output.skills.map(s => `- ${s.name}: ${s.description}`).join('\n') + `\n\nInvoke with Skill("<name>").`,
59
+ }
60
+ },
61
+ renderToolUseMessage(input: { description?: string }) {
62
+ return input.description ?? ''
63
+ },
64
+ renderToolResultMessage(output: Output) {
65
+ return output.skills.length ? output.skills.map(s => s.name).join(', ') : 'no matching skills'
66
+ },
67
+ async call({ description, limit }, context) {
68
+ const commands = ((context.options as { commands?: Command[] } | undefined)?.commands ?? []).filter(c => c.type === 'prompt')
69
+ const hits = searchSkills(description, commands, { limit: limit ?? 5, minScore: 1.5 })
70
+ for (const h of hits) context.discoveredSkillNames?.add(h.name)
71
+ return {
72
+ data: {
73
+ skills: hits.map(h => ({ name: h.name, description: h.description })),
74
+ message: hits.length ? `${hits.length} skills match.` : 'No installed skill matches that description.',
75
+ },
76
+ }
77
+ },
78
+ })
@@ -0,0 +1,3 @@
1
+ export const DISCOVER_SKILLS_TOOL_NAME = 'DiscoverSkills'
2
+
3
+ export const DESCRIPTION = `Find installed skills relevant to what you are about to do. Relevant skills are surfaced automatically each turn as "Skills relevant to your task" reminders; call this when you are starting something those did not cover (a mid-task pivot, an unusual workflow, a multi-step plan) with a specific description of the work. Returns skill names and descriptions; invoke one with Skill("<name>").`
@@ -0,0 +1,89 @@
1
+ import { z } from 'zod/v4'
2
+ import { buildTool } from '../../Tool.js'
3
+ import { lazySchema } from '../../utils/lazySchema.js'
4
+ import { listAllLiveSessions, type LiveSession } from '../../utils/udsClient.js'
5
+
6
+ export const LIST_PEERS_TOOL_NAME = 'ListPeers'
7
+
8
+ const DESCRIPTION = `List the other Xela sessions running on this machine that can receive messages. Each peer has a socket address; send to it with SendMessage({ to: "uds:<socket path>", message: "..." }). The message arrives in that session as a user prompt wrapped in <cross-session-message from="uds:...">; the peer replies by sending to the from address. Use it to coordinate work across projects or hand a question to a session that has the right context.`
9
+
10
+ const inputSchema = lazySchema(() => z.strictObject({}))
11
+ type InputSchema = ReturnType<typeof inputSchema>
12
+
13
+ const outputSchema = lazySchema(() =>
14
+ z.object({
15
+ peers: z.array(
16
+ z.object({
17
+ address: z.string(),
18
+ pid: z.number(),
19
+ cwd: z.string().optional(),
20
+ name: z.string().optional(),
21
+ kind: z.string().optional(),
22
+ startedAt: z.number().optional(),
23
+ }),
24
+ ),
25
+ self: z.string().optional(),
26
+ }),
27
+ )
28
+ type OutputSchema = ReturnType<typeof outputSchema>
29
+ export type Output = z.infer<OutputSchema>
30
+
31
+ export const ListPeersTool = buildTool({
32
+ name: LIST_PEERS_TOOL_NAME,
33
+ searchHint: 'discover other local sessions to message',
34
+ maxResultSizeChars: 20_000,
35
+ userFacingName: () => 'List peers',
36
+ get inputSchema(): InputSchema {
37
+ return inputSchema()
38
+ },
39
+ get outputSchema(): OutputSchema {
40
+ return outputSchema()
41
+ },
42
+ isConcurrencySafe() {
43
+ return true
44
+ },
45
+ isReadOnly() {
46
+ return true
47
+ },
48
+ async checkPermissions(input) {
49
+ return { behavior: 'allow', updatedInput: input }
50
+ },
51
+ async description() {
52
+ return 'List other local sessions'
53
+ },
54
+ async prompt() {
55
+ return DESCRIPTION
56
+ },
57
+ mapToolResultToToolResultBlockParam(output, toolUseID) {
58
+ const lines =
59
+ output.peers.length === 0
60
+ ? ['No other reachable sessions on this machine.']
61
+ : output.peers.map(
62
+ p =>
63
+ `- ${p.address} pid ${p.pid}${p.name ? ` name ${p.name}` : ''}${p.cwd ? ` cwd ${p.cwd}` : ''}${p.kind && p.kind !== 'interactive' ? ` (${p.kind})` : ''}`,
64
+ )
65
+ if (output.self) lines.push(`(this session: uds:${output.self})`)
66
+ return { tool_use_id: toolUseID, type: 'tool_result', content: lines.join('\n') }
67
+ },
68
+ renderToolUseMessage() {
69
+ return ''
70
+ },
71
+ renderToolResultMessage(output: Output) {
72
+ return `${output.peers.length} peer${output.peers.length === 1 ? '' : 's'}`
73
+ },
74
+ async call() {
75
+ const sessions = await listAllLiveSessions()
76
+ const self = sessions.find(s => s.isSelf)
77
+ const peers = sessions
78
+ .filter((s): s is LiveSession & { messagingSocketPath: string } => !s.isSelf && s.reachable && !!s.messagingSocketPath)
79
+ .map(s => ({
80
+ address: `uds:${s.messagingSocketPath}`,
81
+ pid: s.pid,
82
+ cwd: s.cwd,
83
+ name: s.name,
84
+ kind: s.kind,
85
+ startedAt: s.startedAt,
86
+ }))
87
+ return { data: { peers, self: self?.messagingSocketPath } }
88
+ },
89
+ })
@@ -59,6 +59,12 @@ export const MCPTool = buildTool({
59
59
  message: 'MCPTool requires permission.',
60
60
  }
61
61
  },
62
+ // MCP tools can do anything (context-mode's ctx_execute runs shell commands),
63
+ // so auto mode must classify them instead of skipping them as irrelevant
64
+ toAutoClassifierInput(input) {
65
+ const text = JSON.stringify(input)
66
+ return text.length > 4000 ? text.slice(0, 4000) + '…' : text
67
+ },
62
68
  renderToolUseMessage,
63
69
  // Overridden in mcpClient.ts
64
70
  userFacingName: () => 'mcp',
@@ -0,0 +1,273 @@
1
+ import { z } from 'zod/v4'
2
+ import { spawnShellTask } from '../../tasks/LocalShellTask/LocalShellTask.js'
3
+ import { killTask } from '../../tasks/LocalShellTask/killShellTasks.js'
4
+ import { spawnMonitorMcpTask } from '../../tasks/MonitorMcpTask/MonitorMcpTask.js'
5
+ import { buildTool } from '../../Tool.js'
6
+ import type { AgentId } from '../../types/ids.js'
7
+ import { lazySchema } from '../../utils/lazySchema.js'
8
+ import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'
9
+ import { exec } from '../../utils/Shell.js'
10
+ import { jsonStringify } from '../../utils/slowOperations.js'
11
+ import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
12
+ import { TaskOutput } from '../../utils/task/TaskOutput.js'
13
+ import { tailFile } from '../../utils/fsOperations.js'
14
+ import { bashToolHasPermission } from '../BashTool/bashPermissions.js'
15
+ import { createEventEmitter, MAX_EVENTS_PER_MINUTE } from './events.js'
16
+ import { DESCRIPTION, MONITOR_TOOL_NAME } from './prompt.js'
17
+ import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
18
+
19
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
20
+ const MAX_TIMEOUT_MS = 30 * 60 * 1000
21
+ const inputSchema = lazySchema(() =>
22
+ z.strictObject({
23
+ command: z
24
+ .string()
25
+ .optional()
26
+ .describe(
27
+ 'Shell command or script. Each stdout line is an event; exit ends the watch.',
28
+ ),
29
+ ws: z
30
+ .object({
31
+ url: z.string(),
32
+ protocols: z.array(z.string()).optional(),
33
+ })
34
+ .optional()
35
+ .describe(
36
+ 'WebSocket to open. Each text frame is an event; socket close ends the watch. Cannot be combined with command.',
37
+ ),
38
+ description: z
39
+ .string()
40
+ .describe(
41
+ 'Short human-readable description of what you are monitoring (shown in notifications).',
42
+ ),
43
+ timeout_ms: z
44
+ .number()
45
+ .optional()
46
+ .describe(
47
+ 'Kill the monitor after this deadline. Default 300000ms, at most 1800000ms. You are notified at expiry and can re-arm.',
48
+ ),
49
+ }),
50
+ )
51
+ type InputSchema = ReturnType<typeof inputSchema>
52
+ export type MonitorInput = z.infer<InputSchema>
53
+
54
+ const outputSchema = lazySchema(() =>
55
+ z.object({
56
+ taskId: z.string(),
57
+ description: z.string(),
58
+ source: z.enum(['command', 'ws']),
59
+ outputFile: z.string(),
60
+ timeoutMs: z.number(),
61
+ message: z.string(),
62
+ }),
63
+ )
64
+ type OutputSchema = ReturnType<typeof outputSchema>
65
+ export type Output = z.infer<OutputSchema>
66
+
67
+ export function clampTimeout(timeoutMs: number | undefined): number {
68
+ if (!timeoutMs || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
69
+ return DEFAULT_TIMEOUT_MS
70
+ }
71
+ return Math.min(timeoutMs, MAX_TIMEOUT_MS)
72
+ }
73
+
74
+ export const MonitorTool = buildTool({
75
+ name: MONITOR_TOOL_NAME,
76
+ searchHint: 'watch a log, process or socket and get notified on each event',
77
+ maxResultSizeChars: 100_000,
78
+ userFacingName: () => 'Monitor',
79
+ get inputSchema(): InputSchema {
80
+ return inputSchema()
81
+ },
82
+ get outputSchema(): OutputSchema {
83
+ return outputSchema()
84
+ },
85
+ isConcurrencySafe() {
86
+ return true
87
+ },
88
+ isReadOnly() {
89
+ return false
90
+ },
91
+ toAutoClassifierInput(input) {
92
+ return input.command ?? (input.ws ? `ws ${input.ws.url}` : '')
93
+ },
94
+ async validateInput({ command, ws, timeout_ms }) {
95
+ if (!command && !ws) {
96
+ return {
97
+ result: false,
98
+ message: 'Provide either command or ws',
99
+ errorCode: 1,
100
+ }
101
+ }
102
+ if (command && ws) {
103
+ return {
104
+ result: false,
105
+ message: 'command and ws cannot be combined',
106
+ errorCode: 1,
107
+ }
108
+ }
109
+ if (ws && !/^wss?:\/\//.test(ws.url)) {
110
+ return {
111
+ result: false,
112
+ message: 'ws.url must start with ws:// or wss://',
113
+ errorCode: 1,
114
+ }
115
+ }
116
+ if (timeout_ms !== undefined && timeout_ms < 1000) {
117
+ return {
118
+ result: false,
119
+ message: 'timeout_ms must be at least 1000',
120
+ errorCode: 1,
121
+ }
122
+ }
123
+ return { result: true }
124
+ },
125
+ async checkPermissions(input, context) {
126
+ // A shell monitor is a Bash command: same rules, same prompts
127
+ if (input.command) {
128
+ const result = await bashToolHasPermission(
129
+ { command: input.command, description: input.description },
130
+ context,
131
+ )
132
+ // Pass the monitor input through, not the Bash-shaped input
133
+ if (result.behavior === 'allow') {
134
+ return { ...result, updatedInput: input }
135
+ }
136
+ return result
137
+ }
138
+ return {
139
+ behavior: 'ask',
140
+ message: `Open WebSocket ${input.ws?.url ?? ''}`,
141
+ }
142
+ },
143
+ async description({ description }) {
144
+ return description
145
+ },
146
+ async prompt() {
147
+ return DESCRIPTION
148
+ },
149
+ mapToolResultToToolResultBlockParam(output, toolUseID) {
150
+ return {
151
+ tool_use_id: toolUseID,
152
+ type: 'tool_result',
153
+ content: jsonStringify(output),
154
+ }
155
+ },
156
+ renderToolUseMessage,
157
+ renderToolResultMessage,
158
+ async call(input, context) {
159
+ const { abortController, getAppState, setAppState } = context
160
+ const toolUseId = (context as { toolUseId?: string }).toolUseId
161
+ const agentId = (context as { agentId?: AgentId }).agentId
162
+ const timeoutMs = clampTimeout(input.timeout_ms)
163
+ const description = input.description
164
+
165
+ if (input.ws) {
166
+ const handle = await spawnMonitorMcpTask(
167
+ {
168
+ url: input.ws.url,
169
+ protocols: input.ws.protocols,
170
+ description,
171
+ timeoutMs,
172
+ toolUseId,
173
+ agentId,
174
+ },
175
+ { abortController, getAppState, setAppState },
176
+ )
177
+ return {
178
+ data: {
179
+ taskId: handle.taskId,
180
+ description,
181
+ source: 'ws' as const,
182
+ outputFile: getTaskOutputPath(handle.taskId),
183
+ timeoutMs,
184
+ message: `Monitor "${description}" armed on ${input.ws.url} (task ${handle.taskId}, expires in ${Math.round(timeoutMs / 1000)}s). Each text frame arrives as a notification.`,
185
+ },
186
+ }
187
+ }
188
+
189
+ const command = input.command!
190
+ let emitter: ReturnType<typeof createEventEmitter> | null = null
191
+ let seenLines = 0
192
+ let taskId: string | null = null
193
+
194
+ // The poller hands over the last ~100 lines plus the total line count, so
195
+ // new lines are the tail slice beyond what was already delivered.
196
+ function deliver(content: string, totalLines: number): void {
197
+ if (!emitter || totalLines <= seenLines) return
198
+ const lines = content.split('\n')
199
+ if (lines[lines.length - 1] === '') lines.pop()
200
+ const fresh = lines.slice(Math.max(0, lines.length - (totalLines - seenLines)))
201
+ seenLines = totalLines
202
+ for (const line of fresh) {
203
+ if (line.trim() === '') continue
204
+ if (!emitter.push(line)) break
205
+ }
206
+ }
207
+
208
+ const shellCommand = await exec(command, abortController.signal, 'bash', {
209
+ timeout: timeoutMs,
210
+ onProgress(_lastLines, allLines, totalLines) {
211
+ if (process.env.XELA_MONITOR_DEBUG) process.stderr.write(`[monitor] progress total=${totalLines} seen=${seenLines}\n`)
212
+ deliver(allLines, totalLines)
213
+ },
214
+ })
215
+
216
+ const handle = await spawnShellTask(
217
+ {
218
+ command,
219
+ description,
220
+ shellCommand,
221
+ toolUseId,
222
+ agentId,
223
+ kind: 'monitor',
224
+ timeout: timeoutMs,
225
+ },
226
+ { abortController, getAppState, setAppState },
227
+ )
228
+ taskId = handle.taskId
229
+ emitter = createEventEmitter({
230
+ taskId,
231
+ description,
232
+ toolUseId,
233
+ agentId,
234
+ onFlood: () => {
235
+ enqueuePendingNotification({
236
+ value: `Monitor "${description}" (task ${taskId}) produced more than ${MAX_EVENTS_PER_MINUTE} events per minute and was stopped. Restart it with a tighter filter.`,
237
+ mode: 'task-notification',
238
+ priority: 'next',
239
+ agentId,
240
+ })
241
+ if (taskId) killTask(taskId, setAppState)
242
+ },
243
+ })
244
+
245
+ // Progress callbacks only fire while the shared poller watches this task
246
+ const outputId = shellCommand.taskOutput.taskId
247
+ TaskOutput.startPolling(outputId)
248
+ void shellCommand.result.then(async () => {
249
+ TaskOutput.stopPolling(outputId)
250
+ // The script may exit between two polls: deliver whatever the last
251
+ // tick missed, then flush so the events precede the "stream ended" notice
252
+ try {
253
+ const { content } = await tailFile(getTaskOutputPath(outputId), 256 * 1024)
254
+ const total = content.split('\n').filter((l, i, a) => !(i === a.length - 1 && l === '')).length
255
+ deliver(content, Math.max(total, seenLines))
256
+ } catch {
257
+ // output file may be gone already
258
+ }
259
+ emitter?.flush()
260
+ })
261
+
262
+ return {
263
+ data: {
264
+ taskId,
265
+ description,
266
+ source: 'command' as const,
267
+ outputFile: getTaskOutputPath(taskId),
268
+ timeoutMs,
269
+ message: `Monitor "${description}" armed (task ${taskId}, expires in ${Math.round(timeoutMs / 1000)}s). Each stdout line arrives as a notification; the script exiting ends the watch.`,
270
+ },
271
+ }
272
+ },
273
+ })
@@ -0,0 +1,25 @@
1
+ import React from 'react'
2
+ import { MessageResponse } from '../../components/MessageResponse.js'
3
+ import { Text } from '../../ink.js'
4
+ import type { MonitorInput, Output } from './MonitorTool.js'
5
+
6
+ export function renderToolUseMessage(
7
+ input: Partial<MonitorInput>,
8
+ { verbose }: { verbose: boolean },
9
+ ): React.ReactNode {
10
+ const source = input.command ?? input.ws?.url ?? ''
11
+ const shown =
12
+ verbose || source.length <= 80 ? source : source.slice(0, 77) + '…'
13
+ return input.description ? `${input.description} · ${shown}` : shown
14
+ }
15
+
16
+ export function renderToolResultMessage(output: Output): React.ReactNode {
17
+ return (
18
+ <MessageResponse>
19
+ <Text>
20
+ Monitor armed · {output.description} · task {output.taskId} · expires
21
+ in {Math.round(output.timeoutMs / 1000)}s
22
+ </Text>
23
+ </MessageResponse>
24
+ )
25
+ }
@@ -0,0 +1,84 @@
1
+ import {
2
+ OUTPUT_FILE_TAG,
3
+ SUMMARY_TAG,
4
+ TASK_ID_TAG,
5
+ TASK_NOTIFICATION_TAG,
6
+ TOOL_USE_ID_TAG,
7
+ } from '../../constants/xml.js'
8
+ import type { AgentId } from '../../types/ids.js'
9
+ import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'
10
+ import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
11
+ import { escapeXml } from '../../utils/xml.js'
12
+
13
+ // Lines arriving within this window are delivered as one notification
14
+ export const EVENT_BATCH_MS = 200
15
+ // A monitor emitting more than this many notifications per minute is stopped:
16
+ // the filter is too wide and would flood the conversation
17
+ export const MAX_EVENTS_PER_MINUTE = 30
18
+
19
+ /**
20
+ * Builds the batching event emitter shared by the shell and WebSocket
21
+ * monitors. Lines pushed within EVENT_BATCH_MS become one task notification
22
+ * (no <status> tag, so the task stays open). Returns `false` from push() once
23
+ * the rate limit tripped, so the caller can stop the source.
24
+ */
25
+ export function createEventEmitter(opts: {
26
+ taskId: string
27
+ description: string
28
+ toolUseId?: string
29
+ agentId?: AgentId
30
+ onFlood: () => void
31
+ }): { push: (line: string) => boolean; flush: () => void } {
32
+ const { taskId, description, toolUseId, agentId, onFlood } = opts
33
+ const outputPath = getTaskOutputPath(taskId)
34
+ const toolUseIdLine = toolUseId
35
+ ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>`
36
+ : ''
37
+ let pending: string[] = []
38
+ let timer: NodeJS.Timeout | null = null
39
+ let flooded = false
40
+ const recent: number[] = []
41
+
42
+ function flush(): void {
43
+ timer = null
44
+ if (pending.length === 0) return
45
+ const lines = pending
46
+ pending = []
47
+ const now = Date.now()
48
+ recent.push(now)
49
+ while (recent.length > 0 && now - recent[0]! > 60_000) recent.shift()
50
+ if (recent.length > MAX_EVENTS_PER_MINUTE && !flooded) {
51
+ flooded = true
52
+ onFlood()
53
+ return
54
+ }
55
+ const summary = `Monitor "${description}": ${lines.length === 1 ? '1 event' : `${lines.length} events`}`
56
+ const message = `<${TASK_NOTIFICATION_TAG}>
57
+ <${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
58
+ <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
59
+ <${SUMMARY_TAG}>${escapeXml(summary)}</${SUMMARY_TAG}>
60
+ </${TASK_NOTIFICATION_TAG}>
61
+ ${lines.join('\n')}`
62
+ if (process.env.XELA_MONITOR_DEBUG) process.stderr.write(`[monitor] flush ${lines.length} lines -> notification\n`)
63
+ enqueuePendingNotification({
64
+ value: message,
65
+ mode: 'task-notification',
66
+ priority: 'next',
67
+ agentId,
68
+ })
69
+ }
70
+
71
+ return {
72
+ push(line: string): boolean {
73
+ if (flooded) return false
74
+ pending.push(line)
75
+ if (!timer) {
76
+ timer = setTimeout(flush, EVENT_BATCH_MS)
77
+ timer.unref()
78
+ }
79
+ return true
80
+ },
81
+ flush,
82
+ }
83
+ }
84
+