@otto-assistant/otto 0.7.17 → 0.7.19

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.
@@ -49,6 +49,153 @@ function takePendingPermissionContext(contextHash) {
49
49
  pendingPermissionContexts.delete(contextHash);
50
50
  return ctx;
51
51
  }
52
+ /**
53
+ * Render rich tool context from permission metadata for Discord/Telegram.
54
+ * Shows what the agent is actually trying to do — the command, file path,
55
+ * URL, content preview, etc. — so the user can make an informed decision.
56
+ */
57
+ function renderPermissionContext(permission) {
58
+ const tool = permission.permission.toLowerCase();
59
+ const meta = permission.metadata ?? {};
60
+ // Read a string value from metadata, trying multiple key names
61
+ const getStr = (keys, fallback = '') => {
62
+ for (const key of keys) {
63
+ const val = meta[key];
64
+ if (typeof val === 'string' && val.length > 0)
65
+ return val;
66
+ }
67
+ return fallback;
68
+ };
69
+ const truncate = (s, maxLen = 500) => s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s;
70
+ const clipBlock = (s, limit) => s.length > limit ? s.slice(0, limit - 1) + '…' : s;
71
+ switch (tool) {
72
+ case 'bash':
73
+ case 'shell':
74
+ case 'shell_command':
75
+ case 'cmd':
76
+ case 'terminal': {
77
+ const command = getStr(['command', 'cmd', 'script']);
78
+ const description = getStr(['description']);
79
+ if (!command && !description)
80
+ return '';
81
+ const parts = [];
82
+ if (description)
83
+ parts.push(`> *${clipBlock(description, 200)}*`);
84
+ if (command)
85
+ parts.push(`\`\`\`bash\n${clipBlock(command, 800)}\n\`\`\``);
86
+ return parts.join('\n');
87
+ }
88
+ case 'edit':
89
+ case 'multiedit':
90
+ case 'str_replace':
91
+ case 'str_replace_based_edit_tool':
92
+ case 'apply_patch': {
93
+ const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file']);
94
+ const oldString = getStr(['old_string', 'oldString', 'changes', 'diff']);
95
+ const newString = getStr(['new_string', 'newString']);
96
+ if (!filePath && !oldString && !newString)
97
+ return '';
98
+ const parts = [];
99
+ if (filePath)
100
+ parts.push(`**File:** \`${filePath}\``);
101
+ if (oldString) {
102
+ parts.push(`**Replace:**\n\`\`\`diff\n- ${clipBlock(oldString, 400)}\n+ ${clipBlock(newString || '', 400)}\n\`\`\``);
103
+ }
104
+ else if (newString) {
105
+ parts.push(`**New content:**\n\`\`\`\n${clipBlock(newString, 600)}\n\`\`\``);
106
+ }
107
+ return parts.join('\n');
108
+ }
109
+ case 'write':
110
+ case 'create':
111
+ case 'file_write': {
112
+ const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file']);
113
+ const content = getStr(['content', 'text', 'data']);
114
+ if (!filePath && !content)
115
+ return '';
116
+ const parts = [];
117
+ if (filePath)
118
+ parts.push(`**File:** \`${filePath}\``);
119
+ if (content) {
120
+ parts.push(`\`\`\`\n${clipBlock(content, 600)}\n\`\`\``);
121
+ }
122
+ return parts.join('\n');
123
+ }
124
+ case 'webfetch':
125
+ case 'fetch':
126
+ case 'curl':
127
+ case 'wget': {
128
+ const url = getStr(['url', 'uri', 'endpoint']);
129
+ const method = getStr(['method']) || 'GET';
130
+ if (!url)
131
+ return '';
132
+ return `**URL:** \`${method.toUpperCase()}\` ${url}`;
133
+ }
134
+ case 'read': {
135
+ const filePath = getStr(['filePath', 'file_path', 'path', 'file', 'filename']);
136
+ const parentDir = getStr(['parentDir', 'parent_dir', 'directory']);
137
+ if (!filePath && !parentDir)
138
+ return '';
139
+ const parts = [];
140
+ if (filePath)
141
+ parts.push(`**Reading:** \`${filePath}\``);
142
+ if (parentDir)
143
+ parts.push(`**Directory:** \`${parentDir}\``);
144
+ return parts.join('\n');
145
+ }
146
+ case 'list':
147
+ case 'ls': {
148
+ const listPath = getStr(['path', 'directory', 'filePath']);
149
+ if (!listPath)
150
+ return '';
151
+ return `**Listing:** \`${listPath}\``;
152
+ }
153
+ case 'glob': {
154
+ const pattern = getStr(['pattern', 'glob']);
155
+ if (!pattern)
156
+ return '';
157
+ return `**Pattern:** \`${pattern}\``;
158
+ }
159
+ case 'grep': {
160
+ const pattern = getStr(['pattern', 'query']);
161
+ if (!pattern)
162
+ return '';
163
+ return `**Search:** \`${pattern}\``;
164
+ }
165
+ case 'external_directory': {
166
+ const filepath = getStr(['filepath', 'path', 'directory']);
167
+ const parentDir = getStr(['parentDir', 'parent_dir']);
168
+ const parts = [];
169
+ if (filepath)
170
+ parts.push(`**Path:** \`${filepath}\``);
171
+ if (parentDir)
172
+ parts.push(`**Parent:** \`${parentDir}\``);
173
+ return parts.join('\n');
174
+ }
175
+ case 'task':
176
+ case 'subagent': {
177
+ const description = getStr(['description', 'prompt']);
178
+ if (!description)
179
+ return '';
180
+ return `> ${clipBlock(description, 300)}`;
181
+ }
182
+ default: {
183
+ // Generic: show description or first meaningful metadata field
184
+ const description = getStr(['description', 'action', 'operation', 'command']);
185
+ if (description)
186
+ return `> *${clipBlock(description, 300)}*`;
187
+ const relevantKeys = Object.keys(meta).filter((k) => !['sessionID', 'id', 'type'].includes(k));
188
+ if (relevantKeys.length > 0) {
189
+ const preview = relevantKeys
190
+ .slice(0, 3)
191
+ .map((k) => `${k}: ${String(meta[k]).slice(0, 60)}`)
192
+ .join('\n');
193
+ return `\`\`\`\n${clipBlock(preview, 400)}\n\`\`\``;
194
+ }
195
+ return '';
196
+ }
197
+ }
198
+ }
52
199
  /**
53
200
  * Show permission buttons for a permission request.
54
201
  * Displays 3 buttons in a row: Accept, Accept Always, Deny.
@@ -108,11 +255,13 @@ export async function showPermissionButtons({ thread, permission, directory, per
108
255
  const externalDirLine = permission.permission === 'external_directory'
109
256
  ? `Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n`
110
257
  : '';
258
+ const contextStr = renderPermissionContext(permission);
111
259
  const fullContent = `⚠️ **Permission Required**\n` +
112
260
  subtaskLine +
113
261
  `**Type:** \`${permission.permission}\`\n` +
114
262
  externalDirLine +
115
- (patternStr ? `**Pattern:** \`${patternStr}\`` : '');
263
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
264
+ (contextStr ? `\n${contextStr}` : '');
116
265
  const permissionMessage = await thread.send({
117
266
  content: fullContent.slice(0, 1900),
118
267
  components: [actionRow],
@@ -133,12 +282,14 @@ function updatePermissionMessage({ context, status, }) {
133
282
  const externalDirLine = context.permission.permission === 'external_directory'
134
283
  ? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
135
284
  : '';
285
+ const contextStr = renderPermissionContext(context.permission);
136
286
  return message.edit({
137
287
  content: `⚠️ **Permission Required**\n` +
138
288
  `**Type:** \`${context.permission.permission}\`\n` +
139
289
  externalDirLine +
140
290
  (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
141
- status,
291
+ (contextStr ? `\n${contextStr}\n` : '') +
292
+ `\n${status}`,
142
293
  components: [],
143
294
  });
144
295
  })
@@ -1000,7 +1000,12 @@ export class ThreadSessionRuntime {
1000
1000
  })();
1001
1001
  logger.log(`[EVENT] type=${event.type} eventSessionId=${eventSessionId || 'none'} activeSessionId=${sessionId || 'none'} ${this.formatRunStateForLog()}${eventDetails}`);
1002
1002
  }
1003
- const isGlobalEvent = event.type === 'tui.toast.show';
1003
+ // permission.asked and question.asked must always reach the handler
1004
+ // so they get forwarded to Discord/Telegram even when the requesting
1005
+ // session is a subagent whose metadata has been cleared from the buffer.
1006
+ const isGlobalEvent = event.type === 'tui.toast.show' ||
1007
+ event.type === 'permission.asked' ||
1008
+ event.type === 'question.asked';
1004
1009
  const isScopedToastEvent = Boolean(toastSessionId);
1005
1010
  // Drop events that don't match current session (stale events from
1006
1011
  // previous sessions), unless it's a global event or a subtask session.
@@ -1818,8 +1823,11 @@ export class ThreadSessionRuntime {
1818
1823
  const isMainSession = permission.sessionID === sessionId;
1819
1824
  const isSubtaskSession = Boolean(subtaskInfo);
1820
1825
  if (!isMainSession && !isSubtaskSession) {
1821
- logger.log(`[PERMISSION IGNORED] Permission for unknown session (expected: ${sessionId} or subtask, got: ${permission.sessionID})`);
1822
- return;
1826
+ logger.log(`[PERMISSION] Permission for non-main session (expected: ${sessionId} or subtask, got: ${permission.sessionID}) — forwarding to Discord`);
1827
+ // Do not return — still show buttons for the Discord thread.
1828
+ // The session mismatch usually happens when the event buffer has
1829
+ // been cleared and a subagent's permission.asked arrives without
1830
+ // subtask metadata in the buffer. The user needs to see these.
1823
1831
  }
1824
1832
  // Auto-deny external_directory permissions for paths that do not exist
1825
1833
  // on the filesystem. There is no point asking the user to approve access
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "module": "index.ts",
9
9
  "type": "module",
10
- "version": "0.7.17",
10
+ "version": "0.7.19",
11
11
  "scripts": {
12
12
  "dev": "tsx src/bin.ts",
13
13
  "prepublishOnly": "pnpm build",
@@ -92,6 +92,152 @@ function takePendingPermissionContext(contextHash: string): PendingPermissionCon
92
92
  return ctx
93
93
  }
94
94
 
95
+ /**
96
+ * Render rich tool context from permission metadata for Discord/Telegram.
97
+ * Shows what the agent is actually trying to do — the command, file path,
98
+ * URL, content preview, etc. — so the user can make an informed decision.
99
+ */
100
+ function renderPermissionContext(permission: PermissionRequest): string {
101
+ const tool = permission.permission.toLowerCase()
102
+ const meta = permission.metadata ?? {}
103
+
104
+ // Read a string value from metadata, trying multiple key names
105
+ const getStr = (keys: string[], fallback = ''): string => {
106
+ for (const key of keys) {
107
+ const val = meta[key]
108
+ if (typeof val === 'string' && val.length > 0) return val
109
+ }
110
+ return fallback
111
+ }
112
+
113
+ const truncate = (s: string, maxLen = 500): string =>
114
+ s.length > maxLen ? s.slice(0, maxLen - 1) + '…' : s
115
+
116
+ const clipBlock = (s: string, limit: number): string =>
117
+ s.length > limit ? s.slice(0, limit - 1) + '…' : s
118
+
119
+ switch (tool) {
120
+ case 'bash':
121
+ case 'shell':
122
+ case 'shell_command':
123
+ case 'cmd':
124
+ case 'terminal': {
125
+ const command = getStr(['command', 'cmd', 'script'])
126
+ const description = getStr(['description'])
127
+ if (!command && !description) return ''
128
+ const parts: string[] = []
129
+ if (description) parts.push(`> *${clipBlock(description, 200)}*`)
130
+ if (command) parts.push(`\`\`\`bash\n${clipBlock(command, 800)}\n\`\`\``)
131
+ return parts.join('\n')
132
+ }
133
+
134
+ case 'edit':
135
+ case 'multiedit':
136
+ case 'str_replace':
137
+ case 'str_replace_based_edit_tool':
138
+ case 'apply_patch': {
139
+ const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file'])
140
+ const oldString = getStr(['old_string', 'oldString', 'changes', 'diff'])
141
+ const newString = getStr(['new_string', 'newString'])
142
+ if (!filePath && !oldString && !newString) return ''
143
+ const parts: string[] = []
144
+ if (filePath) parts.push(`**File:** \`${filePath}\``)
145
+ if (oldString) {
146
+ parts.push(`**Replace:**\n\`\`\`diff\n- ${clipBlock(oldString, 400)}\n+ ${clipBlock(newString || '', 400)}\n\`\`\``)
147
+ } else if (newString) {
148
+ parts.push(`**New content:**\n\`\`\`\n${clipBlock(newString, 600)}\n\`\`\``)
149
+ }
150
+ return parts.join('\n')
151
+ }
152
+
153
+ case 'write':
154
+ case 'create':
155
+ case 'file_write': {
156
+ const filePath = getStr(['path', 'file_path', 'filename', 'filePath', 'file'])
157
+ const content = getStr(['content', 'text', 'data'])
158
+ if (!filePath && !content) return ''
159
+ const parts: string[] = []
160
+ if (filePath) parts.push(`**File:** \`${filePath}\``)
161
+ if (content) {
162
+ parts.push(`\`\`\`\n${clipBlock(content, 600)}\n\`\`\``)
163
+ }
164
+ return parts.join('\n')
165
+ }
166
+
167
+ case 'webfetch':
168
+ case 'fetch':
169
+ case 'curl':
170
+ case 'wget': {
171
+ const url = getStr(['url', 'uri', 'endpoint'])
172
+ const method = getStr(['method']) || 'GET'
173
+ if (!url) return ''
174
+ return `**URL:** \`${method.toUpperCase()}\` ${url}`
175
+ }
176
+
177
+ case 'read': {
178
+ const filePath = getStr(['filePath', 'file_path', 'path', 'file', 'filename'])
179
+ const parentDir = getStr(['parentDir', 'parent_dir', 'directory'])
180
+ if (!filePath && !parentDir) return ''
181
+ const parts: string[] = []
182
+ if (filePath) parts.push(`**Reading:** \`${filePath}\``)
183
+ if (parentDir) parts.push(`**Directory:** \`${parentDir}\``)
184
+ return parts.join('\n')
185
+ }
186
+
187
+ case 'list':
188
+ case 'ls': {
189
+ const listPath = getStr(['path', 'directory', 'filePath'])
190
+ if (!listPath) return ''
191
+ return `**Listing:** \`${listPath}\``
192
+ }
193
+
194
+ case 'glob': {
195
+ const pattern = getStr(['pattern', 'glob'])
196
+ if (!pattern) return ''
197
+ return `**Pattern:** \`${pattern}\``
198
+ }
199
+
200
+ case 'grep': {
201
+ const pattern = getStr(['pattern', 'query'])
202
+ if (!pattern) return ''
203
+ return `**Search:** \`${pattern}\``
204
+ }
205
+
206
+ case 'external_directory': {
207
+ const filepath = getStr(['filepath', 'path', 'directory'])
208
+ const parentDir = getStr(['parentDir', 'parent_dir'])
209
+ const parts: string[] = []
210
+ if (filepath) parts.push(`**Path:** \`${filepath}\``)
211
+ if (parentDir) parts.push(`**Parent:** \`${parentDir}\``)
212
+ return parts.join('\n')
213
+ }
214
+
215
+ case 'task':
216
+ case 'subagent': {
217
+ const description = getStr(['description', 'prompt'])
218
+ if (!description) return ''
219
+ return `> ${clipBlock(description, 300)}`
220
+ }
221
+
222
+ default: {
223
+ // Generic: show description or first meaningful metadata field
224
+ const description = getStr(['description', 'action', 'operation', 'command'])
225
+ if (description) return `> *${clipBlock(description, 300)}*`
226
+ const relevantKeys = Object.keys(meta).filter(
227
+ (k) => !['sessionID', 'id', 'type'].includes(k),
228
+ )
229
+ if (relevantKeys.length > 0) {
230
+ const preview = relevantKeys
231
+ .slice(0, 3)
232
+ .map((k) => `${k}: ${String(meta[k]).slice(0, 60)}`)
233
+ .join('\n')
234
+ return `\`\`\`\n${clipBlock(preview, 400)}\n\`\`\``
235
+ }
236
+ return ''
237
+ }
238
+ }
239
+ }
240
+
95
241
  /**
96
242
  * Show permission buttons for a permission request.
97
243
  * Displays 3 buttons in a row: Accept, Accept Always, Deny.
@@ -178,12 +324,14 @@ export async function showPermissionButtons({
178
324
  permission.permission === 'external_directory'
179
325
  ? `Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n`
180
326
  : ''
327
+ const contextStr = renderPermissionContext(permission)
181
328
  const fullContent =
182
329
  `⚠️ **Permission Required**\n` +
183
330
  subtaskLine +
184
331
  `**Type:** \`${permission.permission}\`\n` +
185
332
  externalDirLine +
186
- (patternStr ? `**Pattern:** \`${patternStr}\`` : '')
333
+ (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
334
+ (contextStr ? `\n${contextStr}` : '')
187
335
  const permissionMessage = await thread.send({
188
336
  content: fullContent.slice(0, 1900),
189
337
  components: [actionRow],
@@ -215,13 +363,15 @@ function updatePermissionMessage({
215
363
  context.permission.permission === 'external_directory'
216
364
  ? 'Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)\n'
217
365
  : ''
366
+ const contextStr = renderPermissionContext(context.permission)
218
367
  return message.edit({
219
368
  content:
220
369
  `⚠️ **Permission Required**\n` +
221
370
  `**Type:** \`${context.permission.permission}\`\n` +
222
371
  externalDirLine +
223
372
  (patternStr ? `**Pattern:** \`${patternStr}\`\n` : '') +
224
- status,
373
+ (contextStr ? `\n${contextStr}\n` : '') +
374
+ `\n${status}`,
225
375
  components: [],
226
376
  })
227
377
  })
@@ -1452,7 +1452,13 @@ export class ThreadSessionRuntime {
1452
1452
  )
1453
1453
  }
1454
1454
 
1455
- const isGlobalEvent = event.type === 'tui.toast.show'
1455
+ // permission.asked and question.asked must always reach the handler
1456
+ // so they get forwarded to Discord/Telegram even when the requesting
1457
+ // session is a subagent whose metadata has been cleared from the buffer.
1458
+ const isGlobalEvent =
1459
+ event.type === 'tui.toast.show' ||
1460
+ event.type === 'permission.asked' ||
1461
+ event.type === 'question.asked'
1456
1462
  const isScopedToastEvent = Boolean(toastSessionId)
1457
1463
 
1458
1464
  // Drop events that don't match current session (stale events from
@@ -2490,9 +2496,12 @@ export class ThreadSessionRuntime {
2490
2496
 
2491
2497
  if (!isMainSession && !isSubtaskSession) {
2492
2498
  logger.log(
2493
- `[PERMISSION IGNORED] Permission for unknown session (expected: ${sessionId} or subtask, got: ${permission.sessionID})`,
2499
+ `[PERMISSION] Permission for non-main session (expected: ${sessionId} or subtask, got: ${permission.sessionID}) — forwarding to Discord`,
2494
2500
  )
2495
- return
2501
+ // Do not return — still show buttons for the Discord thread.
2502
+ // The session mismatch usually happens when the event buffer has
2503
+ // been cleared and a subagent's permission.asked arrives without
2504
+ // subtask metadata in the buffer. The user needs to see these.
2496
2505
  }
2497
2506
 
2498
2507
  // Auto-deny external_directory permissions for paths that do not exist