kimaki 0.24.0 → 0.26.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 (111) hide show
  1. package/dist/agent-model.e2e.test.js +135 -3
  2. package/dist/analytics.test.js +30 -1
  3. package/dist/cli-commands/send.js +13 -3
  4. package/dist/cli-commands/task.js +26 -7
  5. package/dist/cli-runner.js +66 -70
  6. package/dist/cli-runner.test.js +10 -1
  7. package/dist/cli.js +5 -0
  8. package/dist/cloudflare-transcription.js +56 -0
  9. package/dist/commands/agent.js +87 -74
  10. package/dist/commands/model-variant.js +17 -1
  11. package/dist/commands/model.js +18 -0
  12. package/dist/commands/multioauth.js +169 -1
  13. package/dist/commands/queue.js +64 -0
  14. package/dist/commands/tasks.js +6 -0
  15. package/dist/commands/unset-model.js +5 -20
  16. package/dist/config.js +9 -0
  17. package/dist/database.js +65 -0
  18. package/dist/discord-bot.js +8 -6
  19. package/dist/discord-command-registration.js +17 -12
  20. package/dist/external-directory-permissions.e2e.test.js +82 -0
  21. package/dist/external-directory-project-deny.e2e.test.js +68 -0
  22. package/dist/format-tables.js +53 -5
  23. package/dist/format-tables.test.js +121 -0
  24. package/dist/interaction-handler.js +8 -6
  25. package/dist/ipc-tools-plugin.js +4 -3
  26. package/dist/kimaki-opencode-plugin.js +2 -0
  27. package/dist/message-formatting.js +84 -5
  28. package/dist/message-formatting.test.js +160 -2
  29. package/dist/message-preprocessing.js +2 -25
  30. package/dist/oauth-rotation-shared.js +7 -1
  31. package/dist/openai-auth-state.js +20 -0
  32. package/dist/opencode.js +126 -96
  33. package/dist/queue-advanced-e2e-setup.js +63 -27
  34. package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
  35. package/dist/schema.js +19 -0
  36. package/dist/session-handler/event-stream-state.js +147 -0
  37. package/dist/session-handler/event-stream-state.test.js +437 -1
  38. package/dist/session-handler/thread-session-runtime.js +95 -57
  39. package/dist/store.js +1 -0
  40. package/dist/subagent-rate-limit-plugin.js +13 -2
  41. package/dist/system-message.js +18 -4
  42. package/dist/system-message.test.js +28 -4
  43. package/dist/task-runner.js +143 -12
  44. package/dist/task-schedule.js +12 -0
  45. package/dist/task-schedule.test.js +149 -2
  46. package/dist/voice-handler.js +37 -11
  47. package/dist/xai-auth-plugin.js +237 -0
  48. package/dist/xai-auth-state.js +233 -0
  49. package/dist/xai-auth-state.test.js +183 -0
  50. package/package.json +6 -6
  51. package/skills/profano/SKILL.md +5 -3
  52. package/skills/spiceflow/SKILL.md +24 -1
  53. package/src/agent-model.e2e.test.ts +172 -4
  54. package/src/analytics.test.ts +30 -1
  55. package/src/analytics.ts +1 -0
  56. package/src/cli-commands/send.ts +22 -3
  57. package/src/cli-commands/task.ts +32 -19
  58. package/src/cli-runner.test.ts +16 -1
  59. package/src/cli-runner.ts +88 -89
  60. package/src/cli.ts +11 -0
  61. package/src/cloudflare-transcription.ts +76 -0
  62. package/src/commands/agent.ts +120 -84
  63. package/src/commands/model-variant.ts +26 -0
  64. package/src/commands/model.ts +25 -0
  65. package/src/commands/multioauth.ts +195 -2
  66. package/src/commands/queue.ts +83 -0
  67. package/src/commands/tasks.ts +6 -0
  68. package/src/commands/unset-model.ts +5 -21
  69. package/src/config.ts +10 -0
  70. package/src/database.ts +124 -0
  71. package/src/discord-bot.ts +7 -6
  72. package/src/discord-command-registration.ts +22 -15
  73. package/src/external-directory-permissions.e2e.test.ts +99 -0
  74. package/src/external-directory-project-deny.e2e.test.ts +82 -0
  75. package/src/format-tables.test.ts +126 -0
  76. package/src/format-tables.ts +68 -5
  77. package/src/interaction-handler.ts +10 -5
  78. package/src/ipc-tools-plugin.ts +4 -3
  79. package/src/kimaki-opencode-plugin.ts +2 -0
  80. package/src/message-formatting.test.ts +197 -2
  81. package/src/message-formatting.ts +98 -5
  82. package/src/message-preprocessing.ts +2 -37
  83. package/src/oauth-rotation-shared.ts +6 -1
  84. package/src/openai-auth-state.ts +19 -0
  85. package/src/opencode.ts +132 -111
  86. package/src/queue-advanced-e2e-setup.ts +78 -29
  87. package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
  88. package/src/schema.sql +15 -0
  89. package/src/schema.ts +20 -0
  90. package/src/session-handler/event-stream-state.test.ts +490 -0
  91. package/src/session-handler/event-stream-state.ts +210 -0
  92. package/src/session-handler/model-utils.ts +1 -0
  93. package/src/session-handler/thread-runtime-state.ts +0 -4
  94. package/src/session-handler/thread-session-runtime.ts +108 -69
  95. package/src/store.ts +10 -0
  96. package/src/subagent-rate-limit-plugin.ts +12 -2
  97. package/src/system-message.test.ts +29 -4
  98. package/src/system-message.ts +20 -4
  99. package/src/task-runner.ts +168 -11
  100. package/src/task-schedule.test.ts +172 -2
  101. package/src/task-schedule.ts +23 -2
  102. package/src/voice-handler.ts +37 -10
  103. package/src/xai-auth-plugin.ts +302 -0
  104. package/src/xai-auth-state.test.ts +224 -0
  105. package/src/xai-auth-state.ts +293 -0
  106. package/dist/channel-reference-permissions.e2e.test.js +0 -87
  107. package/dist/commands/add-dir.js +0 -169
  108. package/dist/commands/add-dir.test.js +0 -126
  109. package/src/channel-reference-permissions.e2e.test.ts +0 -104
  110. package/src/commands/add-dir.test.ts +0 -154
  111. package/src/commands/add-dir.ts +0 -244
package/dist/opencode.js CHANGED
@@ -5,11 +5,32 @@
5
5
  // specific project. The server lazily creates and caches an Instance per unique
6
6
  // directory path internally.
7
7
  //
8
- // Per-directory permissions (external_directory rules for worktrees, tmpdir,
9
- // etc.) are passed via session.create({ permission }) at session creation time,
10
- // NOT via the server config. The server config has permissive defaults
11
- // (edit: allow, bash: allow, external_directory: ask) and session-level rules
12
- // override them via opencode's findLast() evaluation (last matching rule wins).
8
+ // Permission layering — READ THIS BEFORE ADDING A PERMISSION RULE.
9
+ //
10
+ // opencode evaluates permissions with findLast() over a flattened list, so the
11
+ // last matching rule wins. The order is:
12
+ //
13
+ // opencode built-in defaults
14
+ // ▼
15
+ // merged config files ── kimaki's generated config, THEN the user's
16
+ // ▼ project opencode.json (deep-merged on top)
17
+ // config.agent.<name>.permission
18
+ // ▼
19
+ // session.permission ── buildSessionPermissions(), always wins
20
+ //
21
+ // Directory ALLOW rules therefore belong in the generated server config, never
22
+ // in session rules or an agent block: a project opencode.json must still be
23
+ // able to `deny` or `ask` for specific folders. Anything placed in
24
+ // session.permission silently overrides the user.
25
+ //
26
+ // external_directory is `{ '*': 'allow' }` by default. opencode's own default
27
+ // is `ask`, which meant the agent had to interrupt the user for ordinary reads
28
+ // outside the project, and an unanswered prompt was auto-rejected on TTL. Users
29
+ // who want stricter behaviour add `deny`/`ask` rules to their own
30
+ // opencode.json, or start kimaki with --restrict-directories.
31
+ //
32
+ // session.permission carries exactly one thing: the worktree original-checkout
33
+ // deny, which must beat user config on purpose.
13
34
  //
14
35
  // Uses errore for type-safe error handling.
15
36
  import { spawn, execFileSync } from 'node:child_process';
@@ -23,7 +44,7 @@ import { fileURLToPath } from 'node:url';
23
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
45
  import { createOpencodeClient, } from '@opencode-ai/sdk/v2';
25
46
  import { restartGlobalEventListener, waitForGlobalEventListener, } from './session-handler/global-event-listener.js';
26
- import { getDataDir, getLockPort, } from './config.js';
47
+ import { getDataDir, getLockPort, getRestrictExternalDirectories, } from './config.js';
27
48
  import { store } from './store.js';
28
49
  import { getHranaUrl } from './hrana-server.js';
29
50
  import * as errore from 'errore';
@@ -369,8 +390,8 @@ async function waitForServer({ port, directory, maxAttempts = 300, startupStderr
369
390
  }
370
391
  // ── Single server lifecycle ──────────────────────────────────────
371
392
  // The server is started lazily on first initializeOpencodeForDirectory() call.
372
- // It uses permissive defaults (edit: allow, bash: allow, external_directory: ask).
373
- // Per-directory permissions are applied at session creation time instead.
393
+ // It uses permissive defaults (edit: allow, bash: allow, webfetch: allow, and
394
+ // external_directory: '*' allow unless --restrict-directories is set).
374
395
  // In-flight promise to prevent concurrent startups from racing
375
396
  let startingServer = null;
376
397
  let preferredStartupDirectory = null;
@@ -465,37 +486,15 @@ async function startSingleServer({ directory, } = {}) {
465
486
  resolvedCommand: resolveOpencodeCommand(),
466
487
  baseArgs: serveArgs,
467
488
  });
468
- // Server config uses permissive defaults. Per-directory external_directory
469
- // permissions are set at session creation time via session.create({ permission }).
470
- // Common directories (tmpdir, ~/.config/opencode, ~/.kimaki) are pre-allowed
471
- // at the server level so they never trigger permission prompts regardless of
472
- // whether session-level rules compose correctly.
473
- const tmpdir = os.tmpdir().replaceAll('\\', '/');
474
- const opencodeConfigDir = path
475
- .join(os.homedir(), '.config', 'opencode')
476
- .replaceAll('\\', '/');
477
- const opensrcDir = path
478
- .join(os.homedir(), '.opensrc')
479
- .replaceAll('\\', '/');
480
- const kimakiDataDir = path
481
- .join(os.homedir(), '.kimaki')
482
- .replaceAll('\\', '/');
483
- // No catch-all '*': 'ask' here — the user's opencode.json default is respected.
484
- // Only allowlist specific known-safe directories at the server level.
485
- const externalDirectoryPermissions = {
486
- '/tmp': 'allow',
487
- '/tmp/*': 'allow',
488
- '/private/tmp': 'allow',
489
- '/private/tmp/*': 'allow',
490
- [tmpdir]: 'allow',
491
- [`${tmpdir}/*`]: 'allow',
492
- [opencodeConfigDir]: 'allow',
493
- [`${opencodeConfigDir}/*`]: 'allow',
494
- [opensrcDir]: 'allow',
495
- [`${opensrcDir}/*`]: 'allow',
496
- [kimakiDataDir]: 'allow',
497
- [`${kimakiDataDir}/*`]: 'allow',
498
- };
489
+ // Server config uses permissive defaults. By default every external directory
490
+ // is allowed: opencode's own 'ask' default produced constant permission
491
+ // prompts for ordinary reads, and users who want protection can add their own
492
+ // `deny`/`ask` rules in opencode.json (project config is loaded after this
493
+ // file, so it wins).
494
+ // With --restrict-directories the old behaviour comes back: only a small set
495
+ // of known-safe paths is pre-allowed and everything else falls through to the
496
+ // user's opencode.json default (which is 'ask' unless they changed it).
497
+ const externalDirectoryPermissions = buildServerExternalDirectoryPermissions();
499
498
  const kimakiShimDirectory = ensureKimakiCommandShim({
500
499
  dataDir: getDataDir(),
501
500
  execPath: process.execPath,
@@ -577,7 +576,11 @@ async function startSingleServer({ directory, } = {}) {
577
576
  webfetch: 'allow',
578
577
  websearch: 'allow',
579
578
  codesearch: 'allow',
580
- external_directory: externalDirectoryPermissions,
579
+ // No external_directory here on purpose. opencode composes agents as
580
+ // merge(defaults, agentSpecific, userConfig) and then appends
581
+ // config.agent.<name>.permission LAST, so anything set here would beat
582
+ // the user's own top-level opencode.json rules. The top-level
583
+ // permission block above already covers this agent.
581
584
  },
582
585
  },
583
586
  },
@@ -817,71 +820,98 @@ export async function initializeOpencodeForDirectory(directory, _options) {
817
820
  };
818
821
  }
819
822
  /**
820
- * Build per-session permission rules for external_directory access.
821
- * These rules are passed to session.create({ permission }) and override
822
- * the server-level defaults via opencode's findLast() evaluation.
823
+ * Known-safe paths that never need an external_directory prompt, used only when
824
+ * --restrict-directories is active. Without the flag every path is allowed and
825
+ * this list is irrelevant.
826
+ */
827
+ function knownSafeExternalDirectories() {
828
+ const tmpdir = os.tmpdir().replaceAll('\\', '/');
829
+ const homeDirectory = ({ relativePath }) => {
830
+ return path.resolve(os.homedir(), relativePath.replaceAll('\\', '/'));
831
+ };
832
+ return [
833
+ '/tmp',
834
+ '/private/tmp',
835
+ tmpdir,
836
+ // The agent can read the global AGENTS.md and opencode config; the path is
837
+ // visible in the system prompt so models routinely try to open it.
838
+ homeDirectory({ relativePath: '.config/opencode' }),
839
+ // The Anthropic plugin rewrites the name in the system prompt, so some
840
+ // models try this misspelled path instead.
841
+ homeDirectory({ relativePath: '.config/openc0de' }),
842
+ // Cached opensrc checkouts.
843
+ homeDirectory({ relativePath: '.opensrc' }),
844
+ // Kimaki data dir (logs, db, etc).
845
+ homeDirectory({ relativePath: '.kimaki' }),
846
+ // Prior opencode tool outputs.
847
+ homeDirectory({ relativePath: '.local/share/opencode/tool-output' }),
848
+ // Language toolchain caches, so builds can inspect downloaded modules.
849
+ homeDirectory({ relativePath: '.cache/zig' }),
850
+ homeDirectory({ relativePath: '.cargo' }),
851
+ homeDirectory({ relativePath: '.cache/go-build' }),
852
+ homeDirectory({ relativePath: 'go/pkg' }),
853
+ ];
854
+ }
855
+ /**
856
+ * Build the server-level `permission.external_directory` value.
823
857
  *
824
- * This replaces the old per-server OPENCODE_CONFIG_CONTENT external_directory
825
- * permissions — now each session carries its own directory-scoped rules.
858
+ * Default: `{ '*': 'allow' }` — no prompt for any directory.
859
+ * With --restrict-directories: an allow-list of known-safe paths only. There is
860
+ * deliberately no catch-all '*': 'ask' entry so opencode's own 'ask' default
861
+ * still applies to everything else.
862
+ *
863
+ * Always an object, never the plain string 'allow'. opencode deep-merges config
864
+ * files (remeda mergeDeep) and this file is loaded before the project's
865
+ * opencode.json, so object keys from the project merge on top of these and win
866
+ * via findLast(). A plain string would instead be replaced wholesale by the
867
+ * project object, dropping allow-all for every unmatched path.
868
+ */
869
+ function buildServerExternalDirectoryPermissions() {
870
+ if (!getRestrictExternalDirectories()) {
871
+ return { [ALL_EXTERNAL_DIRECTORIES_PATTERN]: 'allow' };
872
+ }
873
+ const permissions = {};
874
+ for (const directory of knownSafeExternalDirectories()) {
875
+ permissions[directory] = 'allow';
876
+ permissions[`${directory}/*`] = 'allow';
877
+ }
878
+ return permissions;
879
+ }
880
+ /**
881
+ * Build the per-session permission ruleset passed to session.create/update.
882
+ *
883
+ * Keep this list minimal. Session rules are the LAST ruleset opencode
884
+ * evaluates — `Permission.merge(agent.permission, session.permission)` in
885
+ * session/tools.ts, then `findLast()` in permission/index.ts — so every rule
886
+ * here silently overrides the user's own opencode.json. Only rules that must
887
+ * beat user config belong here.
888
+ *
889
+ * In particular, directory *allow* rules must NOT go here. They live in the
890
+ * server config so a project opencode.json can still deny or ask for specific
891
+ * folders. Putting an `external_directory: '*' allow` rule here would make
892
+ * every user `deny` rule a no-op.
893
+ *
894
+ * The session's own working directory never needs a rule either: opencode skips
895
+ * the external_directory gate entirely for paths inside the active instance
896
+ * (`containsPath` in tool/external-directory.ts).
897
+ *
898
+ * That leaves one rule: worktree isolation. Once a thread moves to a managed
899
+ * worktree, deny the original checkout so the agent stops editing the main repo.
826
900
  */
827
901
  export function buildSessionPermissions({ directory, originalRepoDirectory, }) {
828
902
  // Normalize path separators for cross-platform compatibility (Windows uses backslashes)
829
- const tmpdir = os.tmpdir().replaceAll('\\', '/');
830
903
  const normalizedDirectory = directory.replaceAll('\\', '/');
831
904
  const originalRepo = originalRepoDirectory?.replaceAll('\\', '/');
832
- const rules = [
833
- // Allow tmpdir access
834
- { permission: 'external_directory', pattern: '/tmp', action: 'allow' },
835
- { permission: 'external_directory', pattern: '/tmp/*', action: 'allow' },
836
- { permission: 'external_directory', pattern: '/private/tmp', action: 'allow' },
837
- { permission: 'external_directory', pattern: '/private/tmp/*', action: 'allow' },
838
- { permission: 'external_directory', pattern: tmpdir, action: 'allow' },
839
- { permission: 'external_directory', pattern: `${tmpdir}/*`, action: 'allow' },
840
- // Allow the project directory itself
841
- { permission: 'external_directory', pattern: normalizedDirectory, action: 'allow' },
842
- { permission: 'external_directory', pattern: `${normalizedDirectory}/*`, action: 'allow' },
843
- ];
844
- const homeDirectoryRules = ({ relativePath }) => {
845
- const normalizedRelativePath = relativePath.replaceAll('\\', '/');
846
- const basePattern = path.resolve(os.homedir(), normalizedRelativePath);
847
- return [
848
- { permission: 'external_directory', pattern: basePattern, action: 'allow' },
849
- { permission: 'external_directory', pattern: `${basePattern}/*`, action: 'allow' },
850
- ];
851
- };
852
- // Allow ~/.config/opencode so the agent doesn't get permission prompts when
853
- // it tries to read the global AGENTS.md or opencode config (the path is
854
- // visible in the system prompt, so models sometimes try to read it).
855
- rules.push(...homeDirectoryRules({ relativePath: '.config/opencode' }));
856
- // Allow ~/.config/openc0de too because the Anthropic plugin rewrites the
857
- // name in the system prompt and some models may try to inspect that path.
858
- rules.push(...homeDirectoryRules({ relativePath: '.config/openc0de' }));
859
- // Allow ~/.opensrc so agents can inspect cached opensrc checkouts without
860
- // permission prompts.
861
- rules.push(...homeDirectoryRules({ relativePath: '.opensrc' }));
862
- // Allow ~/.kimaki so the agent can access kimaki data dir (logs, db, etc.)
863
- // without permission prompts.
864
- rules.push(...homeDirectoryRules({ relativePath: '.kimaki' }));
865
- // Allow opencode tool output artifacts under XDG data so agents can inspect
866
- // prior tool outputs without interactive permission prompts.
867
- rules.push(...homeDirectoryRules({ relativePath: '.local/share/opencode/tool-output' }));
868
- // Allow common language caches under the user's home directory so toolchains
869
- // can inspect downloaded modules and artifacts without external_directory prompts.
870
- rules.push(...homeDirectoryRules({ relativePath: '.cache/zig' }), ...homeDirectoryRules({ relativePath: '.cargo' }), ...homeDirectoryRules({ relativePath: '.cache/go-build' }), ...homeDirectoryRules({ relativePath: 'go/pkg' }));
871
- // For worktree sessions: explicitly deny the original checkout so agents do
872
- // not keep editing the main repo after the thread has moved to a managed
873
- // worktree. Deny rules are appended last so they override earlier allow/
874
- // ask defaults via opencode's findLast() evaluation.
875
- if (originalRepo && originalRepo !== normalizedDirectory) {
876
- rules.push(...buildExternalDirectoryPermissionRules({
877
- resolvedPattern: originalRepo,
878
- action: 'deny',
879
- }));
905
+ if (!originalRepo || originalRepo === normalizedDirectory) {
906
+ return [];
880
907
  }
881
- return rules;
908
+ return buildExternalDirectoryPermissionRules({
909
+ resolvedPattern: originalRepo,
910
+ action: 'deny',
911
+ });
882
912
  }
883
913
  const ALL_EXTERNAL_DIRECTORIES_PATTERN = '*';
884
- export function buildExternalDirectoryPermissionRules({ resolvedPattern, action, }) {
914
+ function buildExternalDirectoryPermissionRules({ resolvedPattern, action, }) {
885
915
  if (resolvedPattern === ALL_EXTERNAL_DIRECTORIES_PATTERN) {
886
916
  return [
887
917
  {
@@ -34,8 +34,8 @@ export function chooseLockPort({ channelId }) {
34
34
  }
35
35
  return 51_000 + (Math.abs(hash) % 2_000);
36
36
  }
37
- export const CHANNEL_REFERENCE_EXTERNAL_DIR = path.resolve(process.cwd(), 'tmp', 'kimaki-channel-reference-external');
38
- export const CHANNEL_REFERENCE_EXTERNAL_FILE = `${CHANNEL_REFERENCE_EXTERNAL_DIR}/allowed.txt`;
37
+ export const EXTERNAL_DIRECTORY_PROBE_DIR = path.resolve(process.cwd(), 'tmp', 'kimaki-external-directory-probe');
38
+ export const EXTERNAL_DIRECTORY_PROBE_FILE = `${EXTERNAL_DIRECTORY_PROBE_DIR}/probe.txt`;
39
39
  export function createDiscordJsClient({ restUrl }) {
40
40
  return new Client({
41
41
  intents: [
@@ -265,28 +265,28 @@ export function createDeterministicMatchers() {
265
265
  partDelaysMs: [0, 0, 0, 0, 8_000],
266
266
  },
267
267
  };
268
- const channelReferencePermissionMatcher = {
269
- id: 'channel-reference-permission-marker',
268
+ const externalDirectoryProbeMatcher = {
269
+ id: 'external-directory-probe-marker',
270
270
  priority: 106,
271
271
  when: {
272
272
  lastMessageRole: 'user',
273
- latestUserTextIncludes: 'CHANNEL_REFERENCE_PERMISSION_MARKER',
273
+ latestUserTextIncludes: 'EXTERNAL_DIRECTORY_PROBE_MARKER',
274
274
  },
275
275
  then: {
276
276
  parts: [
277
277
  { type: 'stream-start', warnings: [] },
278
- { type: 'text-start', id: 'channel-reference-permission-start' },
278
+ { type: 'text-start', id: 'external-directory-probe-start' },
279
279
  {
280
280
  type: 'text-delta',
281
- id: 'channel-reference-permission-start',
282
- delta: 'reading referenced channel directory',
281
+ id: 'external-directory-probe-start',
282
+ delta: 'reading external directory',
283
283
  },
284
- { type: 'text-end', id: 'channel-reference-permission-start' },
284
+ { type: 'text-end', id: 'external-directory-probe-start' },
285
285
  {
286
286
  type: 'tool-call',
287
- toolCallId: 'channel-reference-read-call',
287
+ toolCallId: 'external-directory-read-call',
288
288
  toolName: 'read',
289
- input: JSON.stringify({ filePath: CHANNEL_REFERENCE_EXTERNAL_FILE }),
289
+ input: JSON.stringify({ filePath: EXTERNAL_DIRECTORY_PROBE_FILE }),
290
290
  },
291
291
  {
292
292
  type: 'finish',
@@ -296,23 +296,52 @@ export function createDeterministicMatchers() {
296
296
  ],
297
297
  },
298
298
  };
299
- const channelReferencePermissionFollowupMatcher = {
300
- id: 'channel-reference-permission-followup',
299
+ // Fires only when the read above was rejected by a permission rule. Scoped to
300
+ // the probe marker so it cannot cascade into unrelated turns. This is what
301
+ // makes a project-level `deny` visible in Discord: without it an allowed read
302
+ // and a denied read produce identical thread output.
303
+ const externalDirectoryProbeDeniedMatcher = {
304
+ id: 'external-directory-probe-denied',
305
+ priority: 107,
306
+ when: {
307
+ latestUserTextIncludes: 'EXTERNAL_DIRECTORY_PROBE_MARKER',
308
+ rawPromptIncludes: 'prevents you from using this specific tool call',
309
+ },
310
+ then: {
311
+ parts: [
312
+ { type: 'stream-start', warnings: [] },
313
+ { type: 'text-start', id: 'external-directory-probe-denied' },
314
+ {
315
+ type: 'text-delta',
316
+ id: 'external-directory-probe-denied',
317
+ delta: 'external-directory-probe-denied',
318
+ },
319
+ { type: 'text-end', id: 'external-directory-probe-denied' },
320
+ {
321
+ type: 'finish',
322
+ finishReason: 'stop',
323
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
324
+ },
325
+ ],
326
+ },
327
+ };
328
+ const externalDirectoryProbeFollowupMatcher = {
329
+ id: 'external-directory-probe-followup',
301
330
  priority: 105,
302
331
  when: {
303
- latestUserTextIncludes: 'CHANNEL_REFERENCE_PERMISSION_MARKER',
304
- rawPromptIncludes: 'reading referenced channel directory',
332
+ latestUserTextIncludes: 'EXTERNAL_DIRECTORY_PROBE_MARKER',
333
+ rawPromptIncludes: 'reading external directory',
305
334
  },
306
335
  then: {
307
336
  parts: [
308
337
  { type: 'stream-start', warnings: [] },
309
- { type: 'text-start', id: 'channel-reference-permission-followup' },
338
+ { type: 'text-start', id: 'external-directory-probe-followup' },
310
339
  {
311
340
  type: 'text-delta',
312
- id: 'channel-reference-permission-followup',
313
- delta: 'channel-reference-read-done',
341
+ id: 'external-directory-probe-followup',
342
+ delta: 'external-directory-probe-done',
314
343
  },
315
- { type: 'text-end', id: 'channel-reference-permission-followup' },
344
+ { type: 'text-end', id: 'external-directory-probe-followup' },
316
345
  {
317
346
  type: 'finish',
318
347
  finishReason: 'stop',
@@ -733,8 +762,9 @@ export function createDeterministicMatchers() {
733
762
  questionSelectQueueMatcher,
734
763
  permissionTypingMatcher,
735
764
  permissionTypingFollowupMatcher,
736
- channelReferencePermissionMatcher,
737
- channelReferencePermissionFollowupMatcher,
765
+ externalDirectoryProbeMatcher,
766
+ externalDirectoryProbeDeniedMatcher,
767
+ externalDirectoryProbeFollowupMatcher,
738
768
  multiToolMatcher,
739
769
  multiToolFollowupMatcher,
740
770
  undoFileMatcher,
@@ -757,7 +787,7 @@ export const TEST_USER_ID = '200000000000000991';
757
787
  * Each caller should use a unique channelId and dirName to avoid collisions
758
788
  * when vitest runs files in parallel.
759
789
  */
760
- export function setupQueueAdvancedSuite({ channelId, channelName, extraChannels = [], dirName, username, }) {
790
+ export function setupQueueAdvancedSuite({ channelId, channelName, dirName, username, restrictExternalDirectories = false, projectPermission, }) {
761
791
  const ctx = {
762
792
  directories: undefined,
763
793
  discord: undefined,
@@ -765,6 +795,7 @@ export function setupQueueAdvancedSuite({ channelId, channelName, extraChannels
765
795
  testStartTime: Date.now(),
766
796
  };
767
797
  let previousDefaultVerbosity = null;
798
+ let previousRestrictExternalDirectories = null;
768
799
  beforeAll(async () => {
769
800
  ctx.testStartTime = Date.now();
770
801
  ctx.directories = createRunDirectories({ name: dirName });
@@ -777,15 +808,13 @@ export function setupQueueAdvancedSuite({ channelId, channelName, extraChannels
777
808
  process.env['KIMAKI_OPENCODE_SESSION_EVENTS_DIR'] = sessionEventsDir;
778
809
  setDataDir(ctx.directories.dataDir);
779
810
  previousDefaultVerbosity = store.getState().defaultVerbosity;
780
- store.setState({ defaultVerbosity: 'tools_and_text' });
811
+ previousRestrictExternalDirectories = store.getState().restrictExternalDirectories;
812
+ store.setState({ defaultVerbosity: 'tools_and_text', restrictExternalDirectories });
781
813
  const digitalDiscordDbPath = path.join(ctx.directories.dataDir, 'digital-discord.db');
782
814
  ctx.discord = new DigitalDiscord({
783
815
  guild: { name: `${dirName} Guild`, ownerId: TEST_USER_ID },
784
816
  channels: [
785
817
  { id: channelId, name: channelName, type: ChannelType.GuildText },
786
- ...extraChannels.map((channel) => {
787
- return { id: channel.id, name: channel.name, type: ChannelType.GuildText };
788
- }),
789
818
  ],
790
819
  users: [{ id: TEST_USER_ID, username }],
791
820
  dbUrl: `file:${digitalDiscordDbPath}`,
@@ -801,7 +830,9 @@ export function setupQueueAdvancedSuite({ channelId, channelName, extraChannels
801
830
  smallModel: 'deterministic-v3',
802
831
  settings: { strict: false, matchers: createDeterministicMatchers() },
803
832
  });
804
- fs.writeFileSync(path.join(ctx.directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
833
+ fs.writeFileSync(path.join(ctx.directories.projectDirectory, 'opencode.json'), JSON.stringify(projectPermission
834
+ ? { ...opencodeConfig, permission: projectPermission }
835
+ : opencodeConfig, null, 2));
805
836
  const dbPath = path.join(ctx.directories.dataDir, 'discord-sessions.db');
806
837
  const hranaResult = await startHranaServer({ dbPath });
807
838
  if (hranaResult instanceof Error) {
@@ -853,6 +884,11 @@ export function setupQueueAdvancedSuite({ channelId, channelName, extraChannels
853
884
  if (previousDefaultVerbosity) {
854
885
  store.setState({ defaultVerbosity: previousDefaultVerbosity });
855
886
  }
887
+ if (previousRestrictExternalDirectories !== null) {
888
+ store.setState({
889
+ restrictExternalDirectories: previousRestrictExternalDirectories,
890
+ });
891
+ }
856
892
  if (ctx.directories) {
857
893
  fs.rmSync(ctx.directories.dataDir, { recursive: true, force: true });
858
894
  }
@@ -27,6 +27,10 @@ describe('queue advanced: typing around permissions', () => {
27
27
  channelName: 'qa-permission-typing-e2e',
28
28
  dirName: 'qa-permission-typing-e2e',
29
29
  username: 'queue-permission-tester',
30
+ // These tests are about the permission button + typing lifecycle, so they
31
+ // need a permission prompt to actually fire. The shipped default allows
32
+ // every directory, so opt into the --restrict-directories behaviour here.
33
+ restrictExternalDirectories: true,
30
34
  });
31
35
  test('permission prompt pauses typing until user click, then typing resumes for long follow-up step', async () => {
32
36
  await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
package/dist/schema.js CHANGED
@@ -164,6 +164,20 @@ export const scheduled_tasks = sqliteCore.sqliteTable('scheduled_tasks', {
164
164
  sqliteCore.index('scheduled_tasks_channel_id_status_idx').on(table.channel_id, table.status),
165
165
  sqliteCore.index('scheduled_tasks_thread_id_status_idx').on(table.thread_id, table.status),
166
166
  ]);
167
+ export const scheduled_task_runs = sqliteCore.sqliteTable('scheduled_task_runs', {
168
+ id: sqliteCore.integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }).notNull(),
169
+ scheduled_task_id: sqliteCore.integer('scheduled_task_id', { mode: 'number' }).notNull().references(() => scheduled_tasks.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
170
+ status: sqliteCore.text('status', { enum: ['pending', 'running', 'completed', 'skipped', 'failed'] }).notNull().default('pending'),
171
+ thread_id: sqliteCore.text('thread_id'),
172
+ session_id: sqliteCore.text('session_id'),
173
+ project_directory: sqliteCore.text('project_directory'),
174
+ started_at: datetime('started_at').notNull(),
175
+ completed_at: datetime('completed_at'),
176
+ error: sqliteCore.text('error'),
177
+ }, (table) => [
178
+ sqliteCore.index('scheduled_task_runs_task_status_idx').on(table.scheduled_task_id, table.status),
179
+ sqliteCore.index('scheduled_task_runs_session_status_idx').on(table.session_id, table.status),
180
+ ]);
167
181
  export const session_start_sources = sqliteCore.sqliteTable('session_start_sources', {
168
182
  session_id: sqliteCore.text('session_id').primaryKey().notNull(),
169
183
  schedule_kind: sqliteCore.text('schedule_kind', { enum: ['at', 'cron'] }).notNull(),
@@ -215,6 +229,7 @@ export const relations = defineRelations({
215
229
  channel_mention_mode,
216
230
  global_models,
217
231
  scheduled_tasks,
232
+ scheduled_task_runs,
218
233
  session_start_sources,
219
234
  forum_sync_configs,
220
235
  ipc_requests,
@@ -278,8 +293,12 @@ export const relations = defineRelations({
278
293
  scheduled_tasks: {
279
294
  channel: r.one.channel_directories({ from: r.scheduled_tasks.channel_id, to: r.channel_directories.channel_id }),
280
295
  thread: r.one.thread_sessions({ from: r.scheduled_tasks.thread_id, to: r.thread_sessions.thread_id }),
296
+ runs: r.many.scheduled_task_runs(),
281
297
  session_start_sources: r.many.session_start_sources(),
282
298
  },
299
+ scheduled_task_runs: {
300
+ scheduled_task: r.one.scheduled_tasks({ from: r.scheduled_task_runs.scheduled_task_id, to: r.scheduled_tasks.id }),
301
+ },
283
302
  session_start_sources: {
284
303
  scheduled_task: r.one.scheduled_tasks({ from: r.session_start_sources.scheduled_task_id, to: r.scheduled_tasks.id }),
285
304
  },
@@ -184,6 +184,153 @@ export function getCurrentTurnStartTime({ events, sessionId, upToIndex, }) {
184
184
  function getTokenTotal(tokens) {
185
185
  return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
186
186
  }
187
+ function emptyTurnTokenUsage() {
188
+ return {
189
+ input: 0,
190
+ output: 0,
191
+ reasoning: 0,
192
+ cacheRead: 0,
193
+ cacheWrite: 0,
194
+ total: 0,
195
+ cost: 0,
196
+ model: undefined,
197
+ providerID: undefined,
198
+ assistantMessageCount: 0,
199
+ userMessageId: undefined,
200
+ };
201
+ }
202
+ // Latest billed token snapshot for the current user turn.
203
+ // Sums the last message.updated tokens per assistant message id so streaming
204
+ // updates are not double-counted. Scoped to sessionId so subagent idles
205
+ // report their own usage.
206
+ export function getLatestTurnTokenUsage({ events, sessionId, upToIndex, }) {
207
+ const latestUserMessage = getLatestUserMessage({
208
+ events,
209
+ sessionId,
210
+ upToIndex,
211
+ });
212
+ if (!latestUserMessage) {
213
+ return emptyTurnTokenUsage();
214
+ }
215
+ const end = upToIndex ?? events.length - 1;
216
+ const latestByMessageId = new Map();
217
+ for (let i = 0; i <= end; i++) {
218
+ const entry = events[i];
219
+ if (!entry) {
220
+ continue;
221
+ }
222
+ const event = entry.event;
223
+ if (event.type !== 'message.updated') {
224
+ continue;
225
+ }
226
+ const info = event.properties.info;
227
+ if (info.sessionID !== sessionId || info.role !== 'assistant') {
228
+ continue;
229
+ }
230
+ if (info.parentID !== latestUserMessage.id) {
231
+ continue;
232
+ }
233
+ latestByMessageId.set(info.id, info);
234
+ }
235
+ if (latestByMessageId.size === 0) {
236
+ return {
237
+ ...emptyTurnTokenUsage(),
238
+ userMessageId: latestUserMessage?.id,
239
+ };
240
+ }
241
+ const usage = emptyTurnTokenUsage();
242
+ usage.userMessageId = latestUserMessage.id;
243
+ usage.assistantMessageCount = latestByMessageId.size;
244
+ for (const message of latestByMessageId.values()) {
245
+ if (message.tokens) {
246
+ usage.input += message.tokens.input;
247
+ usage.output += message.tokens.output;
248
+ usage.reasoning += message.tokens.reasoning;
249
+ usage.cacheRead += message.tokens.cache.read;
250
+ usage.cacheWrite += message.tokens.cache.write;
251
+ usage.total += message.tokens.total ?? getTokenTotal(message.tokens);
252
+ }
253
+ usage.cost += message.cost;
254
+ usage.model = message.modelID;
255
+ usage.providerID = message.providerID;
256
+ }
257
+ return usage;
258
+ }
259
+ function findFirstUserMessageIndex({ events, userMessageId, upToIndex, }) {
260
+ for (let i = 0; i <= upToIndex; i++) {
261
+ const event = events[i]?.event;
262
+ if (event?.type !== 'message.updated') {
263
+ continue;
264
+ }
265
+ if (event.properties.info.id === userMessageId) {
266
+ return i;
267
+ }
268
+ }
269
+ return undefined;
270
+ }
271
+ function findPreviousIdleIndexInTurn({ events, sessionId, firstUserMessageIndex, beforeIndex, }) {
272
+ for (let i = beforeIndex - 1; i > firstUserMessageIndex; i--) {
273
+ const event = events[i]?.event;
274
+ if (event?.type === 'session.idle' && event.properties.sessionID === sessionId) {
275
+ return i;
276
+ }
277
+ }
278
+ return undefined;
279
+ }
280
+ function subtractTokenUsage({ current, previous, }) {
281
+ return {
282
+ input: current.input - previous.input,
283
+ output: current.output - previous.output,
284
+ reasoning: current.reasoning - previous.reasoning,
285
+ cacheRead: current.cacheRead - previous.cacheRead,
286
+ cacheWrite: current.cacheWrite - previous.cacheWrite,
287
+ total: current.total - previous.total,
288
+ cost: current.cost - previous.cost,
289
+ model: current.model,
290
+ providerID: current.providerID,
291
+ assistantMessageCount: current.assistantMessageCount,
292
+ userMessageId: current.userMessageId,
293
+ };
294
+ }
295
+ // Tokens billed since the previous session.idle in this user turn.
296
+ // Survives process restart because both idles stay in the event buffer.
297
+ export function getIdleTokenUsageDelta({ events, sessionId, idleEventIndex, }) {
298
+ const current = getLatestTurnTokenUsage({
299
+ events,
300
+ sessionId,
301
+ upToIndex: idleEventIndex,
302
+ });
303
+ if (!current.userMessageId) {
304
+ return undefined;
305
+ }
306
+ const firstUserMessageIndex = findFirstUserMessageIndex({
307
+ events,
308
+ userMessageId: current.userMessageId,
309
+ upToIndex: idleEventIndex,
310
+ });
311
+ if (firstUserMessageIndex === undefined) {
312
+ return undefined;
313
+ }
314
+ const previousIdleIndex = findPreviousIdleIndexInTurn({
315
+ events,
316
+ sessionId,
317
+ firstUserMessageIndex,
318
+ beforeIndex: idleEventIndex,
319
+ });
320
+ if (previousIdleIndex === undefined) {
321
+ return current.total > 0 ? current : undefined;
322
+ }
323
+ const previous = getLatestTurnTokenUsage({
324
+ events,
325
+ sessionId,
326
+ upToIndex: previousIdleIndex,
327
+ });
328
+ const delta = subtractTokenUsage({ current, previous });
329
+ if (delta.total <= 0) {
330
+ return undefined;
331
+ }
332
+ return delta;
333
+ }
187
334
  // Scans backward for most recent message.updated with role=assistant for sessionId.
188
335
  // Extracts model, providerID, agent, tokensUsed.
189
336
  export function getLatestRunInfo({ events, sessionId, upToIndex, }) {