kimaki 0.7.0 → 0.8.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 (66) hide show
  1. package/dist/agent-model.e2e.test.js +7 -0
  2. package/dist/anthropic-auth-plugin.js +52 -18
  3. package/dist/cli-send-thread.e2e.test.js +4 -2
  4. package/dist/cli.js +11 -5
  5. package/dist/commands/add-dir.js +57 -10
  6. package/dist/commands/last-sessions.js +120 -0
  7. package/dist/commands/permissions.js +46 -7
  8. package/dist/discord-command-registration.js +5 -0
  9. package/dist/format-tables.test.js +19 -0
  10. package/dist/gateway-proxy.e2e.test.js +4 -1
  11. package/dist/interaction-handler.js +7 -0
  12. package/dist/logger.js +19 -20
  13. package/dist/message-formatting.js +3 -1
  14. package/dist/message-formatting.test.js +43 -1
  15. package/dist/opencode.js +1 -0
  16. package/dist/proxy-ws-preload.cjs +85 -0
  17. package/dist/queue-advanced-abort.e2e.test.js +2 -1
  18. package/dist/queue-advanced-action-buttons.e2e.test.js +2 -0
  19. package/dist/queue-advanced-footer.e2e.test.js +7 -0
  20. package/dist/queue-advanced-model-switch.e2e.test.js +1 -0
  21. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -0
  22. package/dist/queue-advanced-typing-interrupt.e2e.test.js +1 -0
  23. package/dist/queue-drain-after-interactive-ui.e2e.test.js +1 -0
  24. package/dist/queue-interrupt-drain.e2e.test.js +1 -0
  25. package/dist/queue-question-select-drain.e2e.test.js +2 -0
  26. package/dist/runtime-lifecycle.e2e.test.js +6 -4
  27. package/dist/session-handler/thread-session-runtime.js +32 -2
  28. package/dist/system-message.js +43 -29
  29. package/dist/system-message.test.js +47 -29
  30. package/dist/thread-message-queue.e2e.test.js +8 -1
  31. package/dist/undo-redo.e2e.test.js +1 -0
  32. package/dist/voice-message.e2e.test.js +8 -0
  33. package/package.json +9 -10
  34. package/skills/new-skill/SKILL.md +34 -20
  35. package/skills/readme.md +20 -0
  36. package/src/agent-model.e2e.test.ts +7 -0
  37. package/src/anthropic-auth-plugin.ts +64 -20
  38. package/src/cli-send-thread.e2e.test.ts +4 -2
  39. package/src/cli.ts +13 -5
  40. package/src/commands/add-dir.ts +85 -14
  41. package/src/commands/last-sessions.ts +167 -0
  42. package/src/commands/permissions.ts +62 -13
  43. package/src/discord-command-registration.ts +5 -0
  44. package/src/format-tables.test.ts +20 -0
  45. package/src/gateway-proxy.e2e.test.ts +4 -1
  46. package/src/interaction-handler.ts +8 -0
  47. package/src/logger.ts +46 -35
  48. package/src/message-formatting.test.ts +46 -1
  49. package/src/message-formatting.ts +3 -1
  50. package/src/opencode.ts +1 -0
  51. package/src/queue-advanced-abort.e2e.test.ts +2 -1
  52. package/src/queue-advanced-action-buttons.e2e.test.ts +2 -0
  53. package/src/queue-advanced-footer.e2e.test.ts +7 -0
  54. package/src/queue-advanced-model-switch.e2e.test.ts +1 -0
  55. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -0
  56. package/src/queue-advanced-typing-interrupt.e2e.test.ts +1 -0
  57. package/src/queue-drain-after-interactive-ui.e2e.test.ts +1 -0
  58. package/src/queue-interrupt-drain.e2e.test.ts +1 -0
  59. package/src/queue-question-select-drain.e2e.test.ts +2 -0
  60. package/src/runtime-lifecycle.e2e.test.ts +6 -4
  61. package/src/session-handler/thread-session-runtime.ts +48 -2
  62. package/src/system-message.test.ts +47 -29
  63. package/src/system-message.ts +43 -29
  64. package/src/thread-message-queue.e2e.test.ts +8 -1
  65. package/src/undo-redo.e2e.test.ts +1 -0
  66. package/src/voice-message.e2e.test.ts +8 -0
package/dist/logger.js CHANGED
@@ -1,6 +1,6 @@
1
- // Prefixed logging utility using @clack/prompts for consistent visual style.
2
- // All log methods use clack's log.message() with appropriate symbols to prevent
3
- // output interleaving from concurrent async operations.
1
+ // Prefixed logging utility using @clack/prompts for consistent stderr diagnostics and file logs.
2
+ // Never write logger output to stdout because many CLI subcommands print
3
+ // machine-readable data there, for example `kimaki project list --json`.
4
4
  import { log as clackLog } from '@clack/prompts';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
@@ -106,7 +106,7 @@ export function formatErrorWithStack(error) {
106
106
  redactPaths: false,
107
107
  });
108
108
  }
109
- function writeToFile(level, prefix, args) {
109
+ function writeToFile({ level, prefix, args, }) {
110
110
  const timestamp = new Date().toISOString();
111
111
  const message = `[${timestamp}] [${level}] [${prefix}] ${args.map(formatArg).join(' ')}\n`;
112
112
  if (!logFilePath) {
@@ -118,13 +118,10 @@ function getTimestamp() {
118
118
  const now = new Date();
119
119
  return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
120
120
  }
121
- function padPrefix(prefix) {
122
- return prefix.padEnd(MAX_PREFIX_LENGTH);
123
- }
124
- function formatMessage(timestamp, prefix, args) {
121
+ function formatMessage({ timestamp, prefix, args, }) {
125
122
  return [pc.dim(timestamp), prefix, ...args.map(formatArg)].join(' ');
126
123
  }
127
- const noSpacing = { spacing: 0 };
124
+ const stderrLogOptions = { output: process.stderr, spacing: 0 };
128
125
  // Suppress clack terminal output during vitest runs to avoid flooding
129
126
  // test output with hundreds of log lines. File logging still works.
130
127
  // Set KIMAKI_TEST_LOGS=1 when rerunning a failing test to see all
@@ -132,39 +129,41 @@ const noSpacing = { spacing: 0 };
132
129
  const isVitest = !!process.env['KIMAKI_VITEST'];
133
130
  const showTestLogs = isVitest && !!process.env['KIMAKI_TEST_LOGS'];
134
131
  export function createLogger(prefix) {
135
- const paddedPrefix = padPrefix(prefix);
132
+ const paddedPrefix = prefix.padEnd(MAX_PREFIX_LENGTH);
136
133
  const suppressConsole = isVitest && !showTestLogs;
137
134
  const log = (...args) => {
138
- writeToFile('LOG', prefix, args);
135
+ writeToFile({ level: 'LOG', prefix, args });
139
136
  if (suppressConsole) {
140
137
  return;
141
138
  }
142
- clackLog.message(formatMessage(getTimestamp(), pc.cyan(paddedPrefix), args), {
143
- ...noSpacing,
144
- });
139
+ clackLog.message(formatMessage({ timestamp: getTimestamp(), prefix: pc.cyan(paddedPrefix), args }), stderrLogOptions);
145
140
  };
146
141
  return {
147
142
  log,
148
143
  error: (...args) => {
149
- writeToFile('ERROR', prefix, args);
144
+ writeToFile({ level: 'ERROR', prefix, args });
150
145
  if (suppressConsole) {
151
146
  return;
152
147
  }
153
- clackLog.error(formatMessage(getTimestamp(), pc.red(paddedPrefix), args), noSpacing);
148
+ clackLog.error(formatMessage({ timestamp: getTimestamp(), prefix: pc.red(paddedPrefix), args }), stderrLogOptions);
154
149
  },
155
150
  warn: (...args) => {
156
- writeToFile('WARN', prefix, args);
151
+ writeToFile({ level: 'WARN', prefix, args });
157
152
  if (suppressConsole) {
158
153
  return;
159
154
  }
160
- clackLog.warn(formatMessage(getTimestamp(), pc.yellow(paddedPrefix), args), noSpacing);
155
+ clackLog.warn(formatMessage({
156
+ timestamp: getTimestamp(),
157
+ prefix: pc.yellow(paddedPrefix),
158
+ args,
159
+ }), stderrLogOptions);
161
160
  },
162
161
  info: (...args) => {
163
- writeToFile('INFO', prefix, args);
162
+ writeToFile({ level: 'INFO', prefix, args });
164
163
  if (suppressConsole) {
165
164
  return;
166
165
  }
167
- clackLog.info(formatMessage(getTimestamp(), pc.blue(paddedPrefix), args), noSpacing);
166
+ clackLog.info(formatMessage({ timestamp: getTimestamp(), prefix: pc.blue(paddedPrefix), args }), stderrLogOptions);
168
167
  },
169
168
  debug: log,
170
169
  };
@@ -307,7 +307,9 @@ export function formatPart(part, prefix) {
307
307
  }
308
308
  const firstChar = text[0] || '';
309
309
  const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|'];
310
- const startsWithMarkdown = markdownStarters.includes(firstChar) || /^\d+\./.test(text);
310
+ const startsWithMarkdown = markdownStarters.includes(firstChar) ||
311
+ /^\d+\./.test(text) ||
312
+ /^<callout[\s>]/i.test(text);
311
313
  if (startsWithMarkdown) {
312
314
  return `\n${text}`;
313
315
  }
@@ -1,5 +1,47 @@
1
1
  import { describe, test, expect } from 'vitest';
2
- import { formatTodoList } from './message-formatting.js';
2
+ import { formatPart, formatTodoList } from './message-formatting.js';
3
+ describe('formatPart', () => {
4
+ test('callout text does not get ⬥ prefix', () => {
5
+ const part = {
6
+ id: 'test',
7
+ type: 'text',
8
+ sessionID: 'ses_test',
9
+ messageID: 'msg_test',
10
+ text: `<callout accent="#ef4444">\n## Top priority\n- **Stripe dispute** deadline\n</callout>`,
11
+ };
12
+ expect(formatPart(part)).toMatchInlineSnapshot(`
13
+ "
14
+ <callout accent="#ef4444">
15
+ ## Top priority
16
+ - **Stripe dispute** deadline
17
+ </callout>"
18
+ `);
19
+ });
20
+ test('regular text gets ⬥ prefix', () => {
21
+ const part = {
22
+ id: 'test',
23
+ type: 'text',
24
+ sessionID: 'ses_test',
25
+ messageID: 'msg_test',
26
+ text: 'hello world',
27
+ };
28
+ expect(formatPart(part)).toMatchInlineSnapshot(`"⬥ hello world"`);
29
+ });
30
+ test('text starting with heading does not get ⬥ prefix', () => {
31
+ const part = {
32
+ id: 'test',
33
+ type: 'text',
34
+ sessionID: 'ses_test',
35
+ messageID: 'msg_test',
36
+ text: '## Summary\nDone.',
37
+ };
38
+ expect(formatPart(part)).toMatchInlineSnapshot(`
39
+ "
40
+ ## Summary
41
+ Done."
42
+ `);
43
+ });
44
+ });
3
45
  describe('formatTodoList', () => {
4
46
  test('formats active todo with monospace numbers', () => {
5
47
  const part = {
package/dist/opencode.js CHANGED
@@ -521,6 +521,7 @@ async function startSingleServer({ directory, } = {}) {
521
521
  KIMAKI: '1',
522
522
  KIMAKI_DATA_DIR: getDataDir(),
523
523
  KIMAKI_LOCK_PORT: getLockPort().toString(),
524
+ KIMAKI_PARENT_LOCK_PORT: getLockPort().toString(),
524
525
  ...(gatewayToken && { KIMAKI_DB_AUTH_TOKEN: gatewayToken }),
525
526
  // Guard: prevents agents from running `kimaki` root command inside
526
527
  // an OpenCode session, which would steal the lock port and break the bot.
@@ -0,0 +1,85 @@
1
+ // WebSocket proxy preload for environments behind a firewall or VPN (e.g. GFW).
2
+ // Loaded via --require when HTTPS_PROXY / https_proxy / HTTP_PROXY / http_proxy is set.
3
+ //
4
+ // Monkey-patches Module.prototype.require to intercept `ws` module loads and
5
+ // inject an https-proxy-agent into every client-side WebSocket connection.
6
+ // This is needed because:
7
+ // 1. Node.js --use-env-proxy breaks ws WebSocket upgrades (nodejs/node#62054)
8
+ // 2. discord.js doesn't expose an agent option for WebSocket connections
9
+ // 3. @discordjs/ws imports ws via ESM, so require.cache patches don't work
10
+ // 4. The ws library supports agent in options but discord.js doesn't pass it
11
+ //
12
+ // Tracking issues:
13
+ // - nodejs/node#57872 (proxy env var support in Node.js)
14
+ // - nodejs/node#62054 (--use-env-proxy breaks ws)
15
+ // - discordjs/discord.js#10716 (proxy support for WebSocket, closed won't fix)
16
+ 'use strict';
17
+ const proxyUrl = process.env.https_proxy ||
18
+ process.env.HTTPS_PROXY ||
19
+ process.env.http_proxy ||
20
+ process.env.HTTP_PROXY;
21
+ if (!proxyUrl) {
22
+ return;
23
+ }
24
+ let HttpsProxyAgent;
25
+ try {
26
+ HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
27
+ }
28
+ catch {
29
+ // https-proxy-agent not installed; skip patching
30
+ return;
31
+ }
32
+ const agent = new HttpsProxyAgent(proxyUrl);
33
+ const Module = require('module');
34
+ const origRequire = Module.prototype.require;
35
+ Module.prototype.require = function patchedRequire(id) {
36
+ const mod = origRequire.apply(this, arguments);
37
+ // Only intercept the ws module, and only once
38
+ if (id === 'ws' && mod && !mod.__kimakiProxyPatched) {
39
+ const OrigWS = mod.WebSocket || mod;
40
+ if (typeof OrigWS === 'function' && !OrigWS.__kimakiProxyPatched) {
41
+ const PatchedWS = function ProxiedWebSocket(url, protocols, options) {
42
+ // ws allows protocols to be an options object when omitted
43
+ if (typeof protocols === 'object' &&
44
+ protocols !== null &&
45
+ !Array.isArray(protocols)) {
46
+ options = protocols;
47
+ protocols = undefined;
48
+ }
49
+ // Inject agent for client connections only (url !== null).
50
+ // Don't override if caller already provided an agent.
51
+ if (url !== null && (!options || !options.agent)) {
52
+ options = Object.assign({}, options, { agent });
53
+ }
54
+ if (new.target) {
55
+ return Reflect.construct(OrigWS, [url, protocols, options], new.target);
56
+ }
57
+ return new OrigWS(url, protocols, options);
58
+ };
59
+ // Inherit prototype so instanceof checks work
60
+ Object.setPrototypeOf(PatchedWS.prototype, OrigWS.prototype);
61
+ Object.setPrototypeOf(PatchedWS, OrigWS);
62
+ PatchedWS.prototype.constructor = PatchedWS;
63
+ // Copy static constants (CONNECTING, OPEN, CLOSING, CLOSED) and statics
64
+ for (const key of Object.getOwnPropertyNames(OrigWS)) {
65
+ if (key !== 'length' && key !== 'prototype' && key !== 'name') {
66
+ try {
67
+ Object.defineProperty(PatchedWS, key, Object.getOwnPropertyDescriptor(OrigWS, key));
68
+ }
69
+ catch {
70
+ // some properties may not be configurable
71
+ }
72
+ }
73
+ }
74
+ PatchedWS.__kimakiProxyPatched = true;
75
+ mod.WebSocket = PatchedWS;
76
+ // ws/index.js sets module.exports = WebSocket, so patch the default too
77
+ if (typeof mod === 'function') {
78
+ // Can't replace module.exports from here, but the WebSocket property
79
+ // is what @discordjs/ws uses via `import { WebSocket } from "ws"`
80
+ }
81
+ }
82
+ mod.__kimakiProxyPatched = true;
83
+ }
84
+ return mod;
85
+ };
@@ -155,6 +155,7 @@ e2eTest('queue advanced: abort and retry', () => {
155
155
  "--- from: user (queue-advanced-tester)
156
156
  Reply with exactly: abort-no-footer-setup
157
157
  --- from: assistant (TestBot)
158
+ *using deterministic-provider/deterministic-v2*
158
159
  ⬥ ok
159
160
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
160
161
  --- from: user (queue-advanced-tester)
@@ -285,7 +286,7 @@ e2eTest('queue advanced: abort and retry', () => {
285
286
  "--- from: user (queue-advanced-tester)
286
287
  Reply with exactly: force-abort-setup
287
288
  --- from: assistant (TestBot)
288
- ok
289
+ *using deterministic-provider/deterministic-v2*
289
290
  --- from: user (queue-advanced-tester)
290
291
  SLOW_ABORT_MARKER run long response"
291
292
  `);
@@ -122,6 +122,7 @@ describe('queue advanced: action buttons', () => {
122
122
  "--- from: user (queue-action-tester)
123
123
  Reply with exactly: action-button-setup
124
124
  --- from: assistant (TestBot)
125
+ *using deterministic-provider/deterministic-v2*
125
126
  ⬥ ok
126
127
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
127
128
  **Action Required**
@@ -193,6 +194,7 @@ describe('queue advanced: action buttons', () => {
193
194
  "--- from: user (queue-action-tester)
194
195
  Reply with exactly: action-button-dismiss-setup
195
196
  --- from: assistant (TestBot)
197
+ *using deterministic-provider/deterministic-v2*
196
198
  ⬥ ok
197
199
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
198
200
  **Action Required**
@@ -33,6 +33,7 @@ e2eTest('queue advanced: footer emission', () => {
33
33
  "--- from: user (queue-advanced-tester)
34
34
  Reply with exactly: footer-check
35
35
  --- from: assistant (TestBot)
36
+ *using deterministic-provider/deterministic-v2*
36
37
  ⬥ ok
37
38
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
38
39
  `);
@@ -89,6 +90,7 @@ e2eTest('queue advanced: footer emission', () => {
89
90
  "--- from: user (queue-advanced-tester)
90
91
  Reply with exactly: footer-multi-setup
91
92
  --- from: assistant (TestBot)
93
+ *using deterministic-provider/deterministic-v2*
92
94
  ⬥ ok
93
95
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
94
96
  --- from: user (queue-advanced-tester)
@@ -185,6 +187,7 @@ e2eTest('queue advanced: footer emission', () => {
185
187
  "--- from: user (queue-advanced-tester)
186
188
  Reply with exactly: interrupt-footer-setup
187
189
  --- from: assistant (TestBot)
190
+ *using deterministic-provider/deterministic-v2*
188
191
  ⬥ ok
189
192
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
190
193
  --- from: user (queue-advanced-tester)
@@ -265,6 +268,7 @@ e2eTest('queue advanced: footer emission', () => {
265
268
  "--- from: user (queue-advanced-tester)
266
269
  Reply with exactly: plugin-timeout-setup
267
270
  --- from: assistant (TestBot)
271
+ *using deterministic-provider/deterministic-v2*
268
272
  ⬥ ok
269
273
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
270
274
  --- from: user (queue-advanced-tester)
@@ -357,6 +361,7 @@ e2eTest('queue advanced: footer emission', () => {
357
361
  "--- from: user (queue-advanced-tester)
358
362
  TOOL_CALL_FOOTER_MARKER
359
363
  --- from: assistant (TestBot)
364
+ *using deterministic-provider/deterministic-v2*
360
365
  ⬥ running tool
361
366
  ⬥ ok
362
367
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
@@ -411,6 +416,7 @@ e2eTest('queue advanced: footer emission', () => {
411
416
  "--- from: user (queue-advanced-tester)
412
417
  MULTI_TOOL_FOOTER_MARKER
413
418
  --- from: assistant (TestBot)
419
+ *using deterministic-provider/deterministic-v2*
414
420
  ⬥ investigating the issue
415
421
  ⬥ all done, fixed 3 files
416
422
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
@@ -467,6 +473,7 @@ e2eTest('queue advanced: footer emission', () => {
467
473
  "--- from: user (queue-advanced-tester)
468
474
  MULTI_STEP_CHAIN_MARKER
469
475
  --- from: assistant (TestBot)
476
+ *using deterministic-provider/deterministic-v2*
470
477
  ⬥ chain step 1: reading config
471
478
  ⬥ chain step 2: analyzing results
472
479
  ⬥ chain step 3: applying fix
@@ -250,6 +250,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
250
250
  "--- from: user (queue-model-switch-tester)
251
251
  Reply with exactly: model-switcher-setup
252
252
  --- from: assistant (TestBot)
253
+ *using deterministic-provider/deterministic-v2*
253
254
  ⬥ ok
254
255
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
255
256
  Model set for this session:
@@ -85,6 +85,7 @@ describe('queue advanced: typing around permissions', () => {
85
85
  "--- from: user (queue-permission-tester)
86
86
  PERMISSION_TYPING_MARKER
87
87
  --- from: assistant (TestBot)
88
+ *using deterministic-provider/deterministic-v2*
88
89
  ⬥ requesting external read permission
89
90
  ⚠️ **Permission Required**
90
91
  **Type:** \`external_directory\`
@@ -82,6 +82,7 @@ e2eTest('queue advanced: typing interrupt', () => {
82
82
  "--- from: user (queue-advanced-tester)
83
83
  Reply with exactly: typing-stop-interrupt-setup
84
84
  --- from: assistant (TestBot)
85
+ *using deterministic-provider/deterministic-v2*
85
86
  ⬥ ok
86
87
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
87
88
  --- from: user (queue-advanced-tester)
@@ -107,6 +107,7 @@ describe('queue drain with pending interactive UI', () => {
107
107
  "--- from: user (drain-ui-tester)
108
108
  Reply with exactly: drain-button-setup
109
109
  --- from: assistant (TestBot)
110
+ *using deterministic-provider/deterministic-v2*
110
111
  ⬥ ok
111
112
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
112
113
  **Action Required**
@@ -98,6 +98,7 @@ e2eTest('queue + interrupt drain ordering', () => {
98
98
  "--- from: user (interrupt-tester)
99
99
  Reply with exactly: setup-interrupt-drain
100
100
  --- from: assistant (TestBot)
101
+ *using deterministic-provider/deterministic-v2*
101
102
  ⬥ ok
102
103
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
103
104
  --- from: user (interrupt-tester)
@@ -121,6 +121,7 @@ describe('queue drain after question select answer', () => {
121
121
  "--- from: user (question-select-tester)
122
122
  QUESTION_SELECT_QUEUE_MARKER
123
123
  --- from: assistant (TestBot)
124
+ *using deterministic-provider/deterministic-v2*
124
125
  **Select action**
125
126
  How to proceed?
126
127
  ✓ _Alpha_
@@ -233,6 +234,7 @@ describe('queue drain after question select answer', () => {
233
234
  "--- from: user (question-select-tester)
234
235
  QUESTION_SELECT_QUEUE_MARKER second-test
235
236
  --- from: assistant (TestBot)
237
+ *using deterministic-provider/deterministic-v2*
236
238
  **Select action**
237
239
  How to proceed?
238
240
  ✓ _Alpha_
@@ -277,6 +277,7 @@ describe('runtime lifecycle', () => {
277
277
  "--- from: user (lifecycle-tester)
278
278
  Reply with exactly: seq-alpha
279
279
  --- from: assistant (TestBot)
280
+ *using deterministic-provider/deterministic-v2*
280
281
  ⬥ ok
281
282
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
282
283
  --- from: user (lifecycle-tester)
@@ -307,7 +308,7 @@ describe('runtime lifecycle', () => {
307
308
  discord,
308
309
  threadId: thread.id,
309
310
  userId: TEST_USER_ID,
310
- text: 'deterministic-v2',
311
+ text: '%',
311
312
  timeout: 4_000,
312
313
  });
313
314
  const messages = await discord.thread(thread.id).getMessages();
@@ -318,12 +319,13 @@ describe('runtime lifecycle', () => {
318
319
  if (!message.content.startsWith('*')) {
319
320
  return false;
320
321
  }
321
- return message.content.includes('deterministic-v2');
322
+ return message.content.includes('deterministic-v2') && message.content.includes('%');
322
323
  });
323
324
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
324
325
  "--- from: user (lifecycle-tester)
325
326
  Reply with exactly: footer-check
326
327
  --- from: assistant (TestBot)
328
+ *using deterministic-provider/deterministic-v2*
327
329
  ⬥ ok
328
330
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
329
331
  `);
@@ -381,6 +383,7 @@ describe('runtime lifecycle', () => {
381
383
  "--- from: user (lifecycle-tester)
382
384
  Reply with exactly: reconnect-alpha
383
385
  --- from: assistant (TestBot)
386
+ *using deterministic-provider/deterministic-v2*
384
387
  ⬥ ok
385
388
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
386
389
  --- from: user (lifecycle-tester)
@@ -417,8 +420,7 @@ describe('runtime lifecycle', () => {
417
420
  "--- from: user (lifecycle-tester)
418
421
  Reply with exactly: footer-high-usage
419
422
  --- from: assistant (TestBot)
420
- ok
421
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
423
+ *using deterministic-provider/deterministic-v2*"
422
424
  `);
423
425
  const threadText = await discord.thread(thread.id).text();
424
426
  expect(threadText).not.toContain('⬦ context usage');
@@ -16,7 +16,7 @@ import { createLogger, LogPrefix } from '../logger.js';
16
16
  import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
17
17
  import { formatPart } from '../message-formatting.js';
18
18
  import { getChannelVerbosity, getPartMessageIds, setPartMessage, getThreadSession, setThreadSession, getThreadWorktree, setSessionAgent, getVariantCascade, setSessionStartSource, appendSessionEventsSinceLastTimestamp, getSessionEventSnapshot, } from '../database.js';
19
- import { showPermissionButtons, cleanupPermissionContext, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
19
+ import { showPermissionButtons, addPermissionRequestToContext, arePatternsCoveredBy, pendingPermissionContexts, } from '../commands/permissions.js';
20
20
  import { showAskUserQuestionDropdowns, pendingQuestionContexts, cancelPendingQuestion, } from '../commands/ask-question.js';
21
21
  import { showActionButtons, waitForQueuedActionButtonsRequest, pendingActionButtonContexts, cancelPendingActionButtons, } from '../commands/action-buttons.js';
22
22
  import { pendingFileUploadContexts, cancelPendingFileUpload, } from '../commands/file-upload.js';
@@ -1904,7 +1904,7 @@ export class ThreadSessionRuntime {
1904
1904
  if (!pending) {
1905
1905
  return;
1906
1906
  }
1907
- cleanupPermissionContext(pending.contextHash);
1907
+ pendingPermissionContexts.delete(pending.contextHash);
1908
1908
  threadPermissions.delete(properties.requestID);
1909
1909
  if (threadPermissions.size === 0) {
1910
1910
  pendingPermissions.delete(this.thread.id);
@@ -2267,6 +2267,11 @@ export class ThreadSessionRuntime {
2267
2267
  const variantField = thinkingValue
2268
2268
  ? { variant: thinkingValue }
2269
2269
  : {};
2270
+ await this.sendNewSessionModelInfo({
2271
+ createdNewSession,
2272
+ model: modelField,
2273
+ agent: resolvedAgent,
2274
+ });
2270
2275
  // ── Build prompt parts ──────────────────────────────────
2271
2276
  const images = input.images || [];
2272
2277
  const promptWithImagePaths = (() => {
@@ -2837,6 +2842,11 @@ export class ThreadSessionRuntime {
2837
2842
  providerID: earlyModelParam.providerID,
2838
2843
  modelID: earlyModelParam.modelID,
2839
2844
  });
2845
+ await this.sendNewSessionModelInfo({
2846
+ createdNewSession,
2847
+ model: earlyModelParam,
2848
+ agent: earlyAgentPreference,
2849
+ });
2840
2850
  // ── Build prompt parts ────────────────────────────────────
2841
2851
  const images = input.images || [];
2842
2852
  const promptWithImagePaths = (() => {
@@ -2935,6 +2945,7 @@ export class ThreadSessionRuntime {
2935
2945
  command: queuedCommand.name,
2936
2946
  arguments: queuedCommand.arguments + (discordTag ? `\n${discordTag}` : ''),
2937
2947
  agent: earlyAgentPreference,
2948
+ model: `${earlyModelParam.providerID}/${earlyModelParam.modelID}`,
2938
2949
  ...variantField,
2939
2950
  }, { signal: commandSignal });
2940
2951
  });
@@ -3135,6 +3146,25 @@ export class ThreadSessionRuntime {
3135
3146
  }
3136
3147
  return { session, getClient, createdNewSession };
3137
3148
  }
3149
+ /**
3150
+ * Emit the model + agent banner once, before the first prompt or OpenCode
3151
+ * command can produce visible output in a newly-created session thread.
3152
+ */
3153
+ async sendNewSessionModelInfo({ createdNewSession, model, agent, }) {
3154
+ if (!createdNewSession) {
3155
+ return;
3156
+ }
3157
+ const modelLabel = `${model.providerID}/${model.modelID}`;
3158
+ const agentLabel = agent && agent.toLowerCase() !== 'build'
3159
+ ? ` ⋅ ${agent}`
3160
+ : '';
3161
+ const result = await errore.tryAsync(() => {
3162
+ return sendThreadMessage(this.thread, `*using ${modelLabel}${agentLabel}*`, { flags: SILENT_MESSAGE_FLAGS });
3163
+ });
3164
+ if (result instanceof Error) {
3165
+ logger.warn(`[SESSION INFO] Failed to send model info: ${result.message}`);
3166
+ }
3167
+ }
3138
3168
  /**
3139
3169
  * Emit the run footer: duration, model, context%, project info.
3140
3170
  * Triggered directly from the terminal assistant message.updated event so the