kimaki 0.4.83 → 0.4.85

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.
@@ -0,0 +1,117 @@
1
+ // E2e test: queued message must drain after the user answers a pending question
2
+ // via the Discord dropdown select menu. Reproduces a bug where answering via
3
+ // select (not text) leaves queued messages stuck because the session continues
4
+ // processing after the answer and may enter another blocking state.
5
+ import { describe, test, expect } from 'vitest';
6
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
7
+ import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
8
+ import { pendingQuestionContexts } from './commands/ask-question.js';
9
+ const TEXT_CHANNEL_ID = '200000000000001030';
10
+ async function waitForPendingQuestion({ threadId, timeoutMs, }) {
11
+ const start = Date.now();
12
+ while (Date.now() - start < timeoutMs) {
13
+ const entry = [...pendingQuestionContexts.entries()].find(([, context]) => {
14
+ return context.thread.id === threadId;
15
+ });
16
+ if (entry) {
17
+ return { contextHash: entry[0] };
18
+ }
19
+ await new Promise((resolve) => {
20
+ setTimeout(resolve, 100);
21
+ });
22
+ }
23
+ throw new Error('Timed out waiting for pending question context');
24
+ }
25
+ describe('queue drain after question select answer', () => {
26
+ const ctx = setupQueueAdvancedSuite({
27
+ channelId: TEXT_CHANNEL_ID,
28
+ channelName: 'qa-question-select-drain',
29
+ dirName: 'qa-question-select-drain',
30
+ username: 'question-select-tester',
31
+ });
32
+ test('queued message drains after answering question via dropdown select', async () => {
33
+ // 1. Send a message that triggers the question tool
34
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
35
+ content: 'QUESTION_SELECT_QUEUE_MARKER',
36
+ });
37
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
38
+ timeout: 4_000,
39
+ predicate: (t) => {
40
+ return t.name === 'QUESTION_SELECT_QUEUE_MARKER';
41
+ },
42
+ });
43
+ const th = ctx.discord.thread(thread.id);
44
+ // 2. Wait for the question dropdown to appear
45
+ const pending = await waitForPendingQuestion({
46
+ threadId: thread.id,
47
+ timeoutMs: 4_000,
48
+ });
49
+ expect(pending.contextHash).toBeTruthy();
50
+ // Verify dropdown message appeared
51
+ const questionMessages = await waitForBotMessageContaining({
52
+ discord: ctx.discord,
53
+ threadId: thread.id,
54
+ text: 'How to proceed?',
55
+ timeout: 4_000,
56
+ });
57
+ const questionMsg = questionMessages.find((m) => {
58
+ return m.content.includes('How to proceed?');
59
+ });
60
+ expect(questionMsg).toBeTruthy();
61
+ // 3. Queue a message while question is pending
62
+ const { id: queueInteractionId } = await th.user(TEST_USER_ID)
63
+ .runSlashCommand({
64
+ name: 'queue',
65
+ options: [{ name: 'message', type: 3, value: 'Reply with exactly: post-question-drain' }],
66
+ });
67
+ const queueAck = await th.waitForInteractionAck({
68
+ interactionId: queueInteractionId,
69
+ timeout: 4_000,
70
+ });
71
+ if (!queueAck.messageId) {
72
+ throw new Error('Expected /queue response message id');
73
+ }
74
+ // 4. Answer the question via dropdown select (pick first option "Alpha")
75
+ const interaction = await th.user(TEST_USER_ID).selectMenu({
76
+ messageId: questionMsg.id,
77
+ customId: `ask_question:${pending.contextHash}:0`,
78
+ values: ['0'],
79
+ });
80
+ await th.waitForInteractionAck({
81
+ interactionId: interaction.id,
82
+ timeout: 4_000,
83
+ });
84
+ // 5. Queued message should be handed off to OpenCode's own prompt queue
85
+ // after the question reply, so the dispatch indicator appears without
86
+ // waiting for a later natural idle.
87
+ await waitForBotMessageContaining({
88
+ discord: ctx.discord,
89
+ threadId: thread.id,
90
+ text: '» **question-select-tester:** Reply with exactly: post-question-drain',
91
+ timeout: 4_000,
92
+ });
93
+ // 6. Wait for footer from the drained queued message
94
+ await waitForFooterMessage({
95
+ discord: ctx.discord,
96
+ threadId: thread.id,
97
+ timeout: 4_000,
98
+ afterMessageIncludes: '» **question-select-tester:**',
99
+ afterAuthorId: ctx.discord.botUserId,
100
+ });
101
+ const timeline = await th.text({ showInteractions: true });
102
+ expect(timeline).toMatchInlineSnapshot(`
103
+ "--- from: user (question-select-tester)
104
+ QUESTION_SELECT_QUEUE_MARKER
105
+ --- from: assistant (TestBot)
106
+ **Select action**
107
+ How to proceed?
108
+ ✓ _Alpha_
109
+ [user interaction]
110
+ Queued message (position 1)
111
+ [user selects dropdown: 0]
112
+ » **question-select-tester:** Reply with exactly: post-question-drain
113
+ ⬥ ok
114
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
115
+ `);
116
+ }, 20_000);
117
+ });
@@ -1805,6 +1805,49 @@ export class ThreadSessionRuntime {
1805
1805
  return;
1806
1806
  }
1807
1807
  this.onInteractiveUiStateChanged();
1808
+ // When a question is answered and the local queue has items, the model may
1809
+ // continue the same run without ever reaching the local-queue idle gate.
1810
+ // Hand the queued items to OpenCode's own prompt queue immediately instead
1811
+ // of waiting for tryDrainQueue() to see an idle session.
1812
+ if (this.getQueueLength() > 0 && !this.questionReplyQueueHandoffPromise) {
1813
+ logger.log(`[QUESTION REPLIED] Queue has ${this.getQueueLength()} items, handing off to opencode queue`);
1814
+ this.questionReplyQueueHandoffPromise = this.handoffQueuedItemsAfterQuestionReply({
1815
+ sessionId,
1816
+ }).catch((error) => {
1817
+ logger.error('[QUESTION REPLIED] Failed to hand off queued messages:', error);
1818
+ if (error instanceof Error) {
1819
+ void notifyError(error, 'Failed to hand off queued messages after question reply');
1820
+ }
1821
+ }).finally(() => {
1822
+ this.questionReplyQueueHandoffPromise = null;
1823
+ });
1824
+ }
1825
+ }
1826
+ // Detached helper promise for the "question answered while local queue has
1827
+ // items" flow. Prevents starting two overlapping local->opencode queue
1828
+ // handoff sequences when multiple question replies land close together.
1829
+ questionReplyQueueHandoffPromise = null;
1830
+ async handoffQueuedItemsAfterQuestionReply({ sessionId, }) {
1831
+ if (this.listenerAborted) {
1832
+ return;
1833
+ }
1834
+ if (this.state?.sessionId !== sessionId) {
1835
+ logger.log(`[QUESTION REPLIED] Session changed before queue handoff for thread ${this.threadId}`);
1836
+ return;
1837
+ }
1838
+ while (this.state?.sessionId === sessionId) {
1839
+ const next = threadState.dequeueItem(this.threadId);
1840
+ if (!next) {
1841
+ return;
1842
+ }
1843
+ const displayText = next.command
1844
+ ? `/${next.command.name}`
1845
+ : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
1846
+ if (displayText.trim()) {
1847
+ await sendThreadMessage(this.thread, `» **${next.username}:** ${displayText}`);
1848
+ }
1849
+ await this.submitViaOpencodeQueue(next);
1850
+ }
1808
1851
  }
1809
1852
  async handleSessionStatus(properties) {
1810
1853
  const sessionId = this.state?.sessionId;
@@ -2624,12 +2667,18 @@ export class ThreadSessionRuntime {
2624
2667
  if (input.command) {
2625
2668
  const queuedCommand = input.command;
2626
2669
  const commandSignal = AbortSignal.timeout(30_000);
2670
+ // session.command() only accepts FilePart in parts, not text parts.
2671
+ // Append <discord-user /> tag to arguments so external sync can
2672
+ // detect this message came from Discord (same tag as promptAsync).
2673
+ const discordTag = input.username
2674
+ ? `\n<discord-user name="${input.username}" />`
2675
+ : '';
2627
2676
  const commandResponse = await errore.tryAsync(() => {
2628
2677
  return getClient().session.command({
2629
2678
  sessionID: session.id,
2630
2679
  directory: this.sdkDirectory,
2631
2680
  command: queuedCommand.name,
2632
- arguments: queuedCommand.arguments,
2681
+ arguments: queuedCommand.arguments + discordTag,
2633
2682
  agent: earlyAgentPreference,
2634
2683
  ...variantField,
2635
2684
  }, { signal: commandSignal });
package/dist/store.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { createStore } from 'zustand/vanilla';
6
6
  export const store = createStore(() => ({
7
7
  dataDir: null,
8
+ projectsDir: null,
8
9
  defaultVerbosity: 'text_and_essential_tools',
9
10
  defaultMentionMode: false,
10
11
  critiqueEnabled: true,
@@ -54,6 +54,18 @@ bunx critique --web "Short title describing the changes" --filter "src/config.ts
54
54
 
55
55
  The string after \`--web\` becomes the diff page title — make it reflect what the changes do (e.g. "Add retry logic to API client", "Fix auth timeout bug").
56
56
 
57
+ ### fetching user comments from critique diffs
58
+
59
+ Users can add line-level comments (annotations) on any critique diff page via the Agentation widget (bottom-right corner of the diff page). To read those comments:
60
+
61
+ \`\`\`bash
62
+ curl https://critique.work/v/<id>/annotations
63
+ \`\`\`
64
+
65
+ Returns \`text/markdown\` with each annotation showing the file, line, and comment text.
66
+ Use this when the user says they left comments on a critique diff and you need to read them.
67
+ You can also use WebFetch on \`https://critique.work/v/<id>/annotations\` to get the markdown directly.
68
+
57
69
  ### about critique
58
70
 
59
71
  critique is an open source tool (MIT license) at https://github.com/remorses/critique.
@@ -131,7 +143,7 @@ Use random tunnel IDs by default. Only pass \`-t\` when exposing a service that
131
143
  tmux new-session -d -s myapp-dev
132
144
 
133
145
  # Run the dev server with kimaki tunnel inside the session
134
- tmux send-keys -t myapp-dev "kimaki tunnel -p 3000 -- pnpm dev" Enter
146
+ tmux send-keys -t myapp-dev "kimaki tunnel --kill -p 3000 -- pnpm dev" Enter
135
147
  \`\`\`
136
148
 
137
149
  ### getting the tunnel URL
@@ -146,15 +158,15 @@ tmux capture-pane -t myapp-dev -p | grep -i "tunnel"
146
158
  \`\`\`bash
147
159
  # Next.js project
148
160
  tmux new-session -d -s projectname-nextjs-dev-3000
149
- tmux send-keys -t nextjs-dev "kimaki tunnel -p 3000 -- pnpm dev" Enter
161
+ tmux send-keys -t nextjs-dev "kimaki tunnel --kill -p 3000 -- pnpm dev" Enter
150
162
 
151
163
  # Vite project on port 5173
152
164
  tmux new-session -d -s vite-dev-5173
153
- tmux send-keys -t vite-dev "kimaki tunnel -p 5173 -- pnpm dev" Enter
165
+ tmux send-keys -t vite-dev "kimaki tunnel --kill -p 5173 -- pnpm dev" Enter
154
166
 
155
167
  # Custom tunnel ID (only for intentionally public-safe services)
156
168
  tmux new-session -d -s holocron-dev
157
- tmux send-keys -t holocron-dev "kimaki tunnel -p 3000 -t holocron -- pnpm dev" Enter
169
+ tmux send-keys -t holocron-dev "kimaki tunnel --kill -p 3000 -t holocron -- pnpm dev" Enter
158
170
  \`\`\`
159
171
 
160
172
  ### stopping the dev server
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.83",
5
+ "version": "0.4.85",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -25,8 +25,8 @@
25
25
  "prisma": "7.4.2",
26
26
  "tsx": "^4.20.5",
27
27
  "discord-digital-twin": "^0.1.0",
28
- "opencode-cached-provider": "^0.0.1",
29
28
  "opencode-deterministic-provider": "^0.0.1",
29
+ "opencode-cached-provider": "^0.0.1",
30
30
  "db": "^0.0.0"
31
31
  },
32
32
  "dependencies": {
@@ -67,8 +67,8 @@
67
67
  "zod": "^4.3.6",
68
68
  "zustand": "^5.0.11",
69
69
  "errore": "^0.14.1",
70
- "traforo": "^0.1.0",
71
- "libsqlproxy": "^0.1.0"
70
+ "libsqlproxy": "^0.1.0",
71
+ "traforo": "^0.2.0"
72
72
  },
73
73
  "optionalDependencies": {
74
74
  "@discordjs/opus": "^0.10.0",
@@ -79,6 +79,7 @@
79
79
  },
80
80
  "scripts": {
81
81
  "dev": "tsx src/cli.ts",
82
+ "build": "pnpm generate && pnpm tsc",
82
83
  "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
83
84
  "watch": "tsx scripts/watch-session.ts",
84
85
  "generate": "prisma generate && pnpm generate:sql",
@@ -432,11 +432,11 @@ return res.status(response.status).json(response.body)
432
432
 
433
433
  > `matchError` routes by `_tag` and requires an `Error` fallback for plain Error instances. Use `matchErrorPartial` when you only need to handle some cases.
434
434
 
435
- ### Resource Cleanup (defer)
435
+ ### Resource Cleanup (defer) — Replacing try/finally with `using`
436
436
 
437
- errore ships `DisposableStack` and `AsyncDisposableStack` polyfills that work in every runtime. Use them with TypeScript's `using` / `await using` for Go-like `defer` cleanup.
437
+ `try/finally` has a structural problem: **every resource adds a nesting level**. Two resources = two levels of indentation. The business logic gets buried deeper with each resource, and cleanup is split across `finally` blocks far from where the resource was acquired. `await using` + `DisposableStack` keeps the function flat — one `cleanup.defer()` per resource, same indentation whether you have one resource or ten. Cleanup runs automatically in reverse order on every exit path.
438
438
 
439
- **tsconfig requirement:** add `"ESNext.Disposable"` to `lib` so TypeScript knows about `Disposable`, `AsyncDisposable`, `using`, and `await using`:
439
+ **tsconfig requirement:** add `"ESNext.Disposable"` to `lib`:
440
440
 
441
441
  ```jsonc
442
442
  {
@@ -446,28 +446,51 @@ errore ships `DisposableStack` and `AsyncDisposableStack` polyfills that work in
446
446
  }
447
447
  ```
448
448
 
449
- Without this, `using`/`await using` declarations and `Symbol.dispose`/`Symbol.asyncDispose` will produce type errors. The errore polyfill handles the runtime side this setting handles the type side.
449
+ **Beforenested try/finally:**
450
450
 
451
451
  ```ts
452
- import * as errore from 'errore'
452
+ async function importData(url: string, dbUrl: string) {
453
+ const db = await connectDb(dbUrl)
454
+ try {
455
+ const tmpFile = await createTempFile()
456
+ try {
457
+ const data = await (await fetch(url)).text()
458
+ await tmpFile.write(data)
459
+ await db.import(tmpFile.path)
460
+ return { rows: await db.count() }
461
+ } finally {
462
+ await tmpFile.delete()
463
+ }
464
+ } finally {
465
+ await db.close()
466
+ }
467
+ }
468
+ ```
469
+
470
+ **After — flat with `await using`:**
453
471
 
454
- async function processRequest(id: string): Promise<DbError | Result> {
472
+ ```ts
473
+ async function importData(url: string, dbUrl: string): Promise<ImportError | { rows: number }> {
455
474
  await using cleanup = new errore.AsyncDisposableStack()
456
475
 
457
- const db = await connectDb().catch((e) => new DbError({ cause: e }))
476
+ const db = await connectDb(dbUrl).catch((e) => new ImportError({ reason: 'db connect', cause: e }))
458
477
  if (db instanceof Error) return db
459
478
  cleanup.defer(() => db.close())
460
479
 
461
- const cache = await openCache().catch((e) => new CacheError({ cause: e }))
462
- if (cache instanceof Error) return cache
463
- cleanup.defer(() => cache.flush())
480
+ const tmpFile = await createTempFile()
481
+ cleanup.defer(() => tmpFile.delete())
482
+
483
+ const response = await fetch(url).catch((e) => new ImportError({ reason: 'fetch', cause: e }))
484
+ if (response instanceof Error) return response
464
485
 
465
- return result
466
- // cleanup runs in LIFO order: cache.flush(), then db.close()
486
+ await tmpFile.write(await response.text())
487
+ await db.import(tmpFile.path)
488
+ return { rows: await db.count() }
489
+ // cleanup: tmpFile.delete() → db.close()
467
490
  }
468
491
  ```
469
492
 
470
- > `await using` guarantees cleanup runs when the scope exits whether by return, early error return, or thrown exception. Resources are released in reverse order (LIFO), just like Go's `defer`. No `try/finally` nesting.
493
+ > `await using` guarantees cleanup on every exit pathnormal return, early error return, or exception. Resources release in LIFO order. Adding a resource is one line (`cleanup.defer()`), not another nesting level. The errore polyfill handles the runtime; the tsconfig `lib` entry handles the types.
471
494
 
472
495
  ### Fallback Values
473
496
 
@@ -608,6 +631,10 @@ for (const item of items) {
608
631
 
609
632
  > Place `signal.aborted` checks **before** expensive operations (network, db writes, file I/O). Check `isAbortError` **after** async calls that received the signal. Both keep the function responsive to cancellation.
610
633
 
634
+ ## Linting
635
+
636
+ If the project uses [lintcn](https://github.com/remorses/lintcn), read `docs/lintcn.md` for the `no-unhandled-error` rule that catches discarded `Error | T` return values.
637
+
611
638
  ## Pitfalls
612
639
 
613
640
  ### CustomError | Error is ambiguous when CustomError extends Error
@@ -602,6 +602,18 @@ cli.version('1.0.0')
602
602
  cli.parse()
603
603
  ```
604
604
 
605
+ ## `openInBrowser(url)`
606
+
607
+ Opens a URL in the default browser. In non-TTY environments (CI, piped output, agents), prints the URL to stdout instead of opening a browser.
608
+
609
+ ```ts
610
+ import { openInBrowser } from 'goke'
611
+
612
+ openInBrowser('https://example.com/dashboard')
613
+ ```
614
+
615
+ Use this after generating URLs (OAuth callbacks, dashboards, docs links) so interactive users get a browser tab and non-interactive environments get a printable URL.
616
+
605
617
  ## Exposing your CLI as a skill
606
618
 
607
619
  When you build a CLI with goke, the optimal way to create a skill for it is a minimal SKILL.md that tells agents to run `--help` before using the CLI. This way descriptions, examples, and usage patterns live in the CLI code (collocated with the implementation) instead of a separate markdown file that can go stale.