@miphamai/cli 0.21.0 → 0.23.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.
@@ -18,6 +18,8 @@ import { getBackgroundAgentRegistry } from '../agent/background-registry'
18
18
  import { RulesLoader } from './rules-loader'
19
19
  import { UsageTracker } from './usage-tracker'
20
20
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
21
+ import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
22
+ import { getMessageBus } from '../agent/message-bus'
21
23
  import { createT } from '../i18n-core/t'
22
24
  import enUS from '../i18n-core/locales/en-US.json'
23
25
  import zhCN from '../i18n-core/locales/zh-CN.json'
@@ -54,6 +56,36 @@ export class QueryEngine {
54
56
  private permission: PermissionSystem = new PermissionSystem('default'),
55
57
  ) {}
56
58
 
59
+ /** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
60
+ private sessionId: string = 'session-1'
61
+
62
+ /** Set the session identifier (called from index.tsx at startup with the actual session name). */
63
+ setSessionId(id: string): void {
64
+ this.sessionId = id
65
+ }
66
+
67
+ /** Get the current session identifier. */
68
+ getSessionId(): string {
69
+ return this.sessionId
70
+ }
71
+
72
+ /** Poll the cross-session file inbox for messages addressed to this session. */
73
+ async pollCrossSessionInbox(): Promise<void> {
74
+ try {
75
+ const transport = getFileInboxTransport()
76
+ const messages = await transport.poll(this.sessionId)
77
+ if (messages.length > 0) {
78
+ const bus = getMessageBus()
79
+ for (const msg of messages) {
80
+ bus.post(msg.from, msg.to, msg.summary, msg.message, msg.type)
81
+ }
82
+ }
83
+ } catch (err) {
84
+ // Best-effort: don't let a failed inbox poll break the turn
85
+ console.warn('Cross-session inbox poll failed:', err)
86
+ }
87
+ }
88
+
57
89
  /** Register a hook engine for pre/post tool-use lifecycle events. */
58
90
  setHookEngine(hooks: HookEngine): void {
59
91
  this.hookEngine = hooks
@@ -255,9 +287,12 @@ export class QueryEngine {
255
287
  }
256
288
 
257
289
  async *process(userInput: string, signal?: AbortSignal): AsyncGenerator<StreamChunk> {
290
+ // Poll for cross-session messages before each turn
291
+ await this.pollCrossSessionInbox()
292
+
258
293
  // Fire UserPromptSubmit hooks before processing
259
294
  if (this.hookEngine) {
260
- const submitResult = await this.hookEngine.executeUserPromptSubmit(userInput, 'session-1')
295
+ const submitResult = await this.hookEngine.executeUserPromptSubmit(userInput, this.sessionId)
261
296
  if (submitResult.additionalContext) {
262
297
  this.context.addMessage({
263
298
  role: 'user',
@@ -292,7 +327,7 @@ export class QueryEngine {
292
327
  const model = this.registry.getActiveModel()
293
328
  const request = buildRequest(
294
329
  messages,
295
- 'session-1',
330
+ this.sessionId,
296
331
  provider,
297
332
  model,
298
333
  hookConfig.organization_id,
@@ -587,7 +622,7 @@ export class QueryEngine {
587
622
  const model = this.registry.getActiveModel()
588
623
  const request = buildRequest(
589
624
  messages,
590
- 'session-1',
625
+ this.sessionId,
591
626
  provider,
592
627
  model,
593
628
  hookConfig.organization_id,
@@ -762,7 +797,7 @@ export class QueryEngine {
762
797
  // Run PreToolUse hooks
763
798
  let effectiveParams = params
764
799
  if (this.hookEngine) {
765
- const preResult = await this.hookEngine.executePreToolUse(name, params, 'session-1')
800
+ const preResult = await this.hookEngine.executePreToolUse(name, params, this.sessionId)
766
801
  if (!preResult.allowed) {
767
802
  return {
768
803
  success: false,
@@ -778,7 +813,7 @@ export class QueryEngine {
778
813
  try {
779
814
  const result = await tool.execute(effectiveParams, {
780
815
  cwd: process.cwd(),
781
- sessionId: 'session-1',
816
+ sessionId: this.sessionId,
782
817
  provider: this.registry.getActive().config.id,
783
818
  model: this.registry.getActiveModel(),
784
819
  skillsLoader: this.skillsLoader,
@@ -795,7 +830,7 @@ export class QueryEngine {
795
830
 
796
831
  // Run PostToolUse hooks
797
832
  if (this.hookEngine) {
798
- await this.hookEngine.executePostToolUse(name, effectiveParams, result, 'session-1')
833
+ await this.hookEngine.executePostToolUse(name, effectiveParams, result, this.sessionId)
799
834
  }
800
835
 
801
836
  return result
@@ -867,7 +902,7 @@ export class QueryEngine {
867
902
  /** Wrap context compaction with PreCompact/PostCompact hooks. */
868
903
  private async compactWithHooks(heading: string): Promise<void> {
869
904
  if (this.hookEngine) {
870
- const preResult = await this.hookEngine.executePreCompact('session-1')
905
+ const preResult = await this.hookEngine.executePreCompact(this.sessionId)
871
906
  if (preResult.additionalContext) {
872
907
  this.context.addMessage({
873
908
  role: 'user',
@@ -879,7 +914,7 @@ export class QueryEngine {
879
914
  await this.context.compact(heading)
880
915
 
881
916
  if (this.hookEngine) {
882
- const postResult = await this.hookEngine.executePostCompact('session-1')
917
+ const postResult = await this.hookEngine.executePostCompact(this.sessionId)
883
918
  if (postResult.additionalContext) {
884
919
  this.context.addMessage({
885
920
  role: 'user',
@@ -893,7 +928,7 @@ export class QueryEngine {
893
928
  private async *checkStopHook(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
894
929
  if (!this.hookEngine) return
895
930
 
896
- const stopResult = await this.hookEngine.executeStop('session-1')
931
+ const stopResult = await this.hookEngine.executeStop(this.sessionId)
897
932
  if (stopResult.decision === 'block') {
898
933
  // Feed the block reason back to the AI and continue
899
934
  this.context.addMessage({
@@ -9,6 +9,7 @@ import {
9
9
  statSync,
10
10
  } from 'node:fs'
11
11
  import { join } from 'node:path'
12
+ import { createHash } from 'node:crypto'
12
13
  import type { Message } from '../shared/types'
13
14
 
14
15
  export interface SessionMetadata {
@@ -50,7 +51,15 @@ function ensureDir(): void {
50
51
 
51
52
  function filePath(name: string): string {
52
53
  // Sanitize name for filesystem
53
- const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100)
54
+ const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_')
55
+
56
+ // For very long names (>100 chars), use SHA-256 hash suffix to prevent collision
57
+ if (safe.length > 100) {
58
+ const hash = createHash('sha256').update(safe).digest('hex').slice(0, 16)
59
+ const truncated = safe.slice(0, 80)
60
+ return join(SESSIONS_DIR, `${truncated}-${hash}.jsonl`)
61
+ }
62
+
54
63
  return join(SESSIONS_DIR, `${safe}.jsonl`)
55
64
  }
56
65
 
@@ -233,7 +242,11 @@ export class SessionStore {
233
242
  ensureDir()
234
243
  mkdirSync(SUMMARIES_DIR, { recursive: true })
235
244
 
236
- const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100)
245
+ const safeRaw = name.replace(/[^a-zA-Z0-9_-]/g, '_')
246
+ const safe =
247
+ safeRaw.length > 100
248
+ ? `${safeRaw.slice(0, 80)}-${createHash('sha256').update(safeRaw).digest('hex').slice(0, 16)}`
249
+ : safeRaw
237
250
  const summaryPath = join(SUMMARIES_DIR, `${safe}.md`)
238
251
  writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, 'utf-8')
239
252
 
package/src/index.tsx CHANGED
@@ -3,7 +3,18 @@ import { join } from 'node:path'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { render } from 'ink'
5
5
  import { App } from './ui/app'
6
- import { loadConfig, loadInferenceHookConfig, loadCredentialMaskingConfig } from './config/loader'
6
+ import {
7
+ loadConfig,
8
+ loadInferenceHookConfig,
9
+ loadCredentialMaskingConfig,
10
+ loadCrossSessionConfig,
11
+ } from './config/loader'
12
+ import {
13
+ registerActiveSession,
14
+ heartbeatSession,
15
+ unregisterSession,
16
+ createSessionInfo,
17
+ } from './agent/cross-session/discovery'
7
18
  import { bootstrapProviders } from './providers/bootstrap'
8
19
  import { InstructionsLoader } from './core/instructions'
9
20
  import { loadSessionMemories, getMemoryManager } from './core/memory/memory-loader'
@@ -246,6 +257,10 @@ export async function runApp(options: RunOptions): Promise<void> {
246
257
  const inferenceHookConfig = loadInferenceHookConfig()
247
258
  engine.setInferenceHookConfig(inferenceHookConfig)
248
259
 
260
+ // Initialize credential masking pipeline (strategies: Full, Extract, JWT)
261
+ const { initializePipeline } = await import('./core/credential-masker/index')
262
+ initializePipeline()
263
+
249
264
  // Wire credential masking configuration into tools
250
265
  const credentialMaskingConfig = loadCredentialMaskingConfig()
251
266
  const { setCredentialMaskingConfigForRead } = await import('./tools/file/read')
@@ -280,10 +295,36 @@ export async function runApp(options: RunOptions): Promise<void> {
280
295
 
281
296
  engine.setupContextSummarizer()
282
297
 
298
+ // ── Cross-session messaging: register session, heartbeat, shutdown ──
299
+ engine.setSessionId(sessionName)
300
+ const sessionInfo = createSessionInfo(
301
+ sessionName,
302
+ sessionName,
303
+ process.cwd(),
304
+ defaultProvider,
305
+ defaultModel,
306
+ )
307
+ registerActiveSession(sessionInfo)
308
+
309
+ // 30-second heartbeat to keep the session file mtime fresh
310
+ const heartbeatInterval = setInterval(() => {
311
+ heartbeatSession(sessionName)
312
+ }, 30_000)
313
+
314
+ // Allow heartbeat to not keep the process alive
315
+ if (heartbeatInterval.unref) {
316
+ heartbeatInterval.unref()
317
+ }
318
+
319
+ // Load cross-session config (available for MessageRouter and future consumers)
320
+ loadCrossSessionConfig()
321
+
283
322
  // Auto-save session on exit
284
323
  let saved = false
285
324
  const saveAndExit = () => {
286
325
  saved = true
326
+ clearInterval(heartbeatInterval)
327
+ unregisterSession(sessionName)
287
328
  artifactServer.stop()
288
329
  if (context.getMessageCount() > 0) {
289
330
  SessionStore.save(sessionName, context.getMessages(), {
@@ -300,6 +341,8 @@ export async function runApp(options: RunOptions): Promise<void> {
300
341
 
301
342
  // Safety net: auto-save on exit for paths that bypass saveAndExit
302
343
  process.on('exit', () => {
344
+ clearInterval(heartbeatInterval)
345
+ unregisterSession(sessionName)
303
346
  if (!saved && context.getMessageCount() > 0) {
304
347
  SessionStore.autoSave(context.getMessages(), {
305
348
  provider: defaultProvider,
@@ -3,7 +3,7 @@
3
3
  * Strips invisible/control characters that could hide command content
4
4
  * from visual inspection or enable homoglyph attacks.
5
5
  *
6
- * Claude Code v2.1.223 parity: hidden command text via tabs/invisible Unicode.
6
+ * Protects against hidden command text via tabs/invisible Unicode characters.
7
7
  */
8
8
 
9
9
  const DANGEROUS_UNICODE = /[​‌‍‎‏‪‫‬‭‮⁠⁦⁧⁨⁩]/g
@@ -375,17 +375,44 @@ export interface CredentialFullMaskRule {
375
375
  mode: 'full'
376
376
  }
377
377
 
378
+ /** Per-extract-pattern configuration with optional field-based extraction. */
379
+ export interface CredentialExtractPattern {
380
+ /** Regex pattern to match. When `field` is set, applied to field value only. */
381
+ pattern: string
382
+ /** Optional replacement string. Defaults to CREDENTIAL_SENTINEL. */
383
+ replacement?: string
384
+ /** 🆕 JSON key to extract before applying pattern. If unset, pattern runs against full content. */
385
+ field?: string
386
+ }
387
+
378
388
  /** Extract-based masking rule: only regex-matched tokens are replaced. */
379
389
  export interface CredentialExtractRule {
380
390
  path: string
381
391
  mode: 'extract'
382
- extract: Array<{
383
- pattern: string
384
- replacement?: string
385
- }>
392
+ extract: CredentialExtractPattern[]
393
+ /** 🆕 Behavior when no extract pattern matches: 'mask' (replace all) or 'passthrough' (keep). Default: 'mask'. */
394
+ onExtractNoMatch?: 'mask' | 'passthrough'
395
+ }
396
+
397
+ /** 🆕 JWT-aware masking rule: decode payload and mask specified claims. */
398
+ export interface JwtMaskingRule {
399
+ path: string
400
+ type: 'jwt'
401
+ decode: 'jwt'
402
+ /** Claim names to mask in the JWT payload (e.g. ["sub", "email"]). */
403
+ maskClaims: string[]
386
404
  }
387
405
 
388
- export type CredentialFileRule = CredentialFullMaskRule | CredentialExtractRule
406
+ /** 🆕 AWS credential pair masking rule: detect key pairs and optionally re-sign. */
407
+ export interface AwsMaskingRule {
408
+ path: string
409
+ type: 'aws'
410
+ awsPairs: boolean
411
+ sigv4: boolean
412
+ }
413
+
414
+ export type CredentialFileRule =
415
+ CredentialFullMaskRule | CredentialExtractRule | JwtMaskingRule | AwsMaskingRule
389
416
 
390
417
  /** Configuration for credential masking, loaded from config.yml. */
391
418
  export interface CredentialMaskingConfig {
@@ -401,6 +428,29 @@ export interface CredentialMaskingConfig {
401
428
  }
402
429
  }
403
430
 
431
+ // ── Cross-Session Messaging Types ──
432
+
433
+ /** Controls how inbound cross-session messages are handled. */
434
+ export type CrossSessionInbound = 'allow' | 'ask' | 'deny'
435
+
436
+ /** 🆕 Configuration for cross-session messaging. */
437
+ export interface CrossSessionConfig {
438
+ crossSessionInbound: CrossSessionInbound
439
+ dialogExpiry: number // seconds
440
+ }
441
+
442
+ /** Session information exposed via ListAgents. */
443
+ export interface SessionInfo {
444
+ id: string
445
+ name: string
446
+ machine: string
447
+ pid: number
448
+ startedAt: string
449
+ cwd?: string
450
+ provider?: string
451
+ model?: string
452
+ }
453
+
404
454
  // ── Background Agent Types ──
405
455
 
406
456
  export interface BackgroundAgentConfig {
@@ -0,0 +1,67 @@
1
+ import type { ToolDefinition } from '../../shared/index.ts'
2
+ import { discoverSessions } from '../../agent/cross-session/discovery'
3
+
4
+ export const listAgentsTool: ToolDefinition = {
5
+ name: 'ListAgents',
6
+ description:
7
+ 'Discover active Mipham Code sessions running on this machine. ' +
8
+ 'Returns session ID, name, machine, working directory, provider, and model for each session. ' +
9
+ 'Use with SendMessage to communicate across sessions.',
10
+ category: 'agent',
11
+ permission: 'auto',
12
+ parameters: {
13
+ type: 'object',
14
+ properties: {
15
+ scope: {
16
+ type: 'string',
17
+ enum: ['local', 'all'],
18
+ default: 'local',
19
+ description:
20
+ 'Discovery scope. "local" scans this machine only. "all" is reserved for future network discovery.',
21
+ },
22
+ },
23
+ },
24
+ async execute(params) {
25
+ const scope = (params.scope as string) || 'local'
26
+
27
+ if (scope === 'all') {
28
+ return {
29
+ success: true,
30
+ content:
31
+ 'Network discovery is not yet available. Showing local sessions only.\n\n' +
32
+ formatSessionList(discoverSessions()),
33
+ }
34
+ }
35
+
36
+ const sessions = discoverSessions()
37
+
38
+ if (sessions.length === 0) {
39
+ return {
40
+ success: true,
41
+ content: 'No active Mipham Code sessions found on this machine.',
42
+ }
43
+ }
44
+
45
+ return {
46
+ success: true,
47
+ content: formatSessionList(sessions),
48
+ }
49
+ },
50
+ }
51
+
52
+ function formatSessionList(sessions: import('../../shared/types').SessionInfo[]): string {
53
+ const lines: string[] = [`${sessions.length} active session(s):\n`]
54
+
55
+ for (const s of sessions) {
56
+ const modelInfo = s.model ? ` · ${s.model}` : ''
57
+ const providerInfo = s.provider ? ` (${s.provider})` : ''
58
+ lines.push(` ${s.id}`)
59
+ lines.push(` Name: ${s.name}`)
60
+ lines.push(` Machine: ${s.machine}${modelInfo}${providerInfo}`)
61
+ lines.push(` PID: ${s.pid} · Started: ${s.startedAt}`)
62
+ if (s.cwd) lines.push(` CWD: ${s.cwd}`)
63
+ lines.push('')
64
+ }
65
+
66
+ return lines.join('\n').trim()
67
+ }
@@ -1,13 +1,12 @@
1
1
  import type { ToolDefinition } from '../../shared/index.ts'
2
- import { getMessageBus } from '../../agent/message-bus'
2
+ import { getMessageRouter } from '../../agent/message-router'
3
3
 
4
4
  export const sendMessageTool: ToolDefinition = {
5
5
  name: 'SendMessage',
6
6
  description:
7
- 'Send a message to another agent or the main conversation. ' +
8
- 'Use "main" as the recipient to message the parent session, ' +
9
- 'or a background task ID (e.g., "bg-1-xxx") to message a specific agent. ' +
10
- 'Messages are stored in the AgentMessageBus and can be polled by the recipient.',
7
+ 'Send a message to another agent or session. ' +
8
+ 'Use "main" for the parent conversation, a background task ID for same-process agents, ' +
9
+ 'or a session ID for cross-session messaging (use ListAgents to discover sessions).',
11
10
  category: 'agent',
12
11
  permission: 'auto',
13
12
  parameters: {
@@ -16,7 +15,7 @@ export const sendMessageTool: ToolDefinition = {
16
15
  to: {
17
16
  type: 'string',
18
17
  description:
19
- 'Recipient: "main" for the parent conversation, a background task ID, or an agent name.',
18
+ 'Recipient: "main" for the parent conversation, a background task ID, or a session ID for cross-session messaging.',
20
19
  },
21
20
  summary: {
22
21
  type: 'string',
@@ -34,34 +33,32 @@ export const sendMessageTool: ToolDefinition = {
34
33
  const summary = (params.summary as string) || '(no subject)'
35
34
  const message = params.message as string
36
35
 
37
- // Determine sender: use sessionId or 'main'
38
36
  const from =
39
37
  ctx.sessionId === 'sub-agent'
40
38
  ? `sub-agent-${Date.now().toString(36)}`
41
39
  : ctx.sessionId || 'main'
42
40
 
43
- try {
44
- const bus = getMessageBus()
45
- const msgId = bus.post(from, to, summary, message)
41
+ const router = getMessageRouter()
42
+ const result = await router.route(from, to, summary, message)
46
43
 
47
- const unreadForRecipient = bus.unreadCount(to)
48
-
49
- return {
50
- success: true,
51
- content:
52
- `── Message Sent ──\n\n` +
53
- `ID: ${msgId}\n` +
54
- `From: ${from}\n` +
55
- `To: ${to}\n` +
56
- `Summary: ${summary.slice(0, 100)}\n\n` +
57
- `The recipient has ${unreadForRecipient} unread message(s).`,
58
- }
59
- } catch (err) {
44
+ if (!result.success) {
60
45
  return {
61
46
  success: false,
62
47
  content: '',
63
- error: `Failed to send message: ${String(err)}`,
48
+ error: `Failed to send message: ${result.error}`,
64
49
  }
65
50
  }
51
+
52
+ const routedLabel = result.routedTo === 'bus' ? 'in-process' : 'cross-session'
53
+
54
+ return {
55
+ success: true,
56
+ content:
57
+ `── Message Sent (${routedLabel}) ──\n\n` +
58
+ `ID: ${result.messageId}\n` +
59
+ `From: ${from}\n` +
60
+ `To: ${to}\n` +
61
+ `Summary: ${summary.slice(0, 100)}`,
62
+ }
66
63
  },
67
64
  }
@@ -122,6 +122,57 @@ function isBlocked(command: string): string | null {
122
122
  return null // safe
123
123
  }
124
124
 
125
+ /**
126
+ * Detect sandbox violations from stderr output.
127
+ * Parses common OS-level error patterns indicating denied access.
128
+ */
129
+ export function detectViolations(stderr: string): string[] {
130
+ const violations: string[] = []
131
+
132
+ // File access violations
133
+ const accessPatterns = /(?:Permission denied|EACCES|EPERM|Operation not permitted)/gi
134
+ const accessMatches = stderr.match(accessPatterns)
135
+ if (accessMatches && accessMatches.length > 0) {
136
+ // Extract file paths from error messages
137
+ const pathPattern =
138
+ /(?:Permission denied|EACCES|EPERM|Operation not permitted).*?['"]?(\/[^\s'"]+)['"]?/gi
139
+ const paths: string[] = []
140
+ let match: RegExpExecArray | null
141
+ while ((match = pathPattern.exec(stderr)) !== null) {
142
+ paths.push(match[1]!)
143
+ }
144
+
145
+ if (paths.length > 0) {
146
+ violations.push(` File access denied: ${paths.join(', ')}`)
147
+ } else {
148
+ violations.push(` File access denied (${accessMatches.length} occurrence(s))`)
149
+ }
150
+ }
151
+
152
+ // Network access violations
153
+ const netPatterns =
154
+ /(?:Network is unreachable|Connection refused|ECONNREFUSED|ENETUNREACH|Could not resolve host|Name or service not known|ETIMEDOUT|Connection timed out)/gi
155
+ const netMatches = stderr.match(netPatterns)
156
+ if (netMatches && netMatches.length > 0) {
157
+ // Extract host:port from error messages
158
+ const hostPattern =
159
+ /(?:connect to|Could not resolve host|Failed to connect to)\s+([^\s:]+(?::\d+)?)/gi
160
+ const hosts: string[] = []
161
+ let match: RegExpExecArray | null
162
+ while ((match = hostPattern.exec(stderr)) !== null) {
163
+ hosts.push(match[1]!)
164
+ }
165
+
166
+ if (hosts.length > 0) {
167
+ violations.push(` Network access denied: ${hosts.join(', ')}`)
168
+ } else {
169
+ violations.push(` Network access denied (${netMatches.length} occurrence(s))`)
170
+ }
171
+ }
172
+
173
+ return violations
174
+ }
175
+
125
176
  export const bashTool: ToolDefinition = {
126
177
  name: 'Bash',
127
178
  description:
@@ -173,6 +224,9 @@ export const bashTool: ToolDefinition = {
173
224
  const exitCode = await proc.exited
174
225
  clearTimeout(timer)
175
226
 
227
+ // Read stderr for violation detection and error reporting
228
+ const rawStderr = await new Response(proc.stderr).text()
229
+
176
230
  // ── Credential masking: scrub output ──
177
231
  let output = rawOutput
178
232
  if (credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled) {
@@ -180,20 +234,30 @@ export const bashTool: ToolDefinition = {
180
234
  output = maskOutput(rawOutput, credentialConfig)
181
235
  }
182
236
 
237
+ // ── Sandbox violation detection ──
238
+ const violations = detectViolations(rawStderr)
239
+
183
240
  if (exitCode !== 0) {
184
- const rawStderr = await new Response(proc.stderr).text()
185
241
  const stderr =
186
242
  credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled
187
243
  ? (await import('../../core/credential-masker')).maskOutput(rawStderr, credentialConfig)
188
244
  : rawStderr
245
+ let errorContent = output.slice(0, 5_000)
246
+ if (violations.length > 0) {
247
+ errorContent += '\n\n── Sandbox Violations ──\n' + violations.join('\n')
248
+ }
189
249
  return {
190
250
  success: false,
191
- content: output.slice(0, 5_000),
251
+ content: errorContent,
192
252
  error: `Exit code ${exitCode}: ${stderr.slice(0, 1_000)}`,
193
253
  }
194
254
  }
195
255
 
196
- return { success: true, content: output.slice(0, 100_000) || '(no output)' }
256
+ let successContent = output.slice(0, 100_000) || '(no output)'
257
+ if (violations.length > 0) {
258
+ successContent += '\n\n── Sandbox Violations ──\n' + violations.join('\n')
259
+ }
260
+ return { success: true, content: successContent }
197
261
  } catch (err) {
198
262
  return {
199
263
  success: false,
@@ -29,6 +29,7 @@ import { toolSearchTool } from './system/tool-search'
29
29
  import { artifactTool } from './artifact/artifact'
30
30
  import { reportFindingsTool } from './agent/report-findings'
31
31
  import { sendMessageTool } from './agent/send-message'
32
+ import { listAgentsTool } from './agent/list-agents'
32
33
  import { computerUseTool } from './computer/computer-use'
33
34
  import { scheduleWakeupTool } from './scheduling/schedule-wakeup.js'
34
35
  import { cronCreateTool, cronDeleteTool, cronListTool } from './scheduling/cron.js'
@@ -145,6 +146,7 @@ export function createToolRegistry(): Map<string, ToolDefinition> {
145
146
  withValidation(workflowTool),
146
147
  withValidation(reportFindingsTool),
147
148
  withValidation(sendMessageTool),
149
+ withValidation(listAgentsTool),
148
150
  // Network tools
149
151
  withValidation(webFetchTool),
150
152
  withValidation(webSearchTool),