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.
- package/dist/agent-model.e2e.test.js +135 -3
- package/dist/analytics.test.js +30 -1
- package/dist/cli-commands/send.js +13 -3
- package/dist/cli-commands/task.js +26 -7
- package/dist/cli-runner.js +66 -70
- package/dist/cli-runner.test.js +10 -1
- package/dist/cli.js +5 -0
- package/dist/cloudflare-transcription.js +56 -0
- package/dist/commands/agent.js +87 -74
- package/dist/commands/model-variant.js +17 -1
- package/dist/commands/model.js +18 -0
- package/dist/commands/multioauth.js +169 -1
- package/dist/commands/queue.js +64 -0
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/config.js +9 -0
- package/dist/database.js +65 -0
- package/dist/discord-bot.js +8 -6
- package/dist/discord-command-registration.js +17 -12
- package/dist/external-directory-permissions.e2e.test.js +82 -0
- package/dist/external-directory-project-deny.e2e.test.js +68 -0
- package/dist/format-tables.js +53 -5
- package/dist/format-tables.test.js +121 -0
- package/dist/interaction-handler.js +8 -6
- package/dist/ipc-tools-plugin.js +4 -3
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/message-formatting.js +84 -5
- package/dist/message-formatting.test.js +160 -2
- package/dist/message-preprocessing.js +2 -25
- package/dist/oauth-rotation-shared.js +7 -1
- package/dist/openai-auth-state.js +20 -0
- package/dist/opencode.js +126 -96
- package/dist/queue-advanced-e2e-setup.js +63 -27
- package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
- package/dist/schema.js +19 -0
- package/dist/session-handler/event-stream-state.js +147 -0
- package/dist/session-handler/event-stream-state.test.js +437 -1
- package/dist/session-handler/thread-session-runtime.js +95 -57
- package/dist/store.js +1 -0
- package/dist/subagent-rate-limit-plugin.js +13 -2
- package/dist/system-message.js +18 -4
- package/dist/system-message.test.js +28 -4
- package/dist/task-runner.js +143 -12
- package/dist/task-schedule.js +12 -0
- package/dist/task-schedule.test.js +149 -2
- package/dist/voice-handler.js +37 -11
- package/dist/xai-auth-plugin.js +237 -0
- package/dist/xai-auth-state.js +233 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +6 -6
- package/skills/profano/SKILL.md +5 -3
- package/skills/spiceflow/SKILL.md +24 -1
- package/src/agent-model.e2e.test.ts +172 -4
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/cli-commands/send.ts +22 -3
- package/src/cli-commands/task.ts +32 -19
- package/src/cli-runner.test.ts +16 -1
- package/src/cli-runner.ts +88 -89
- package/src/cli.ts +11 -0
- package/src/cloudflare-transcription.ts +76 -0
- package/src/commands/agent.ts +120 -84
- package/src/commands/model-variant.ts +26 -0
- package/src/commands/model.ts +25 -0
- package/src/commands/multioauth.ts +195 -2
- package/src/commands/queue.ts +83 -0
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/config.ts +10 -0
- package/src/database.ts +124 -0
- package/src/discord-bot.ts +7 -6
- package/src/discord-command-registration.ts +22 -15
- package/src/external-directory-permissions.e2e.test.ts +99 -0
- package/src/external-directory-project-deny.e2e.test.ts +82 -0
- package/src/format-tables.test.ts +126 -0
- package/src/format-tables.ts +68 -5
- package/src/interaction-handler.ts +10 -5
- package/src/ipc-tools-plugin.ts +4 -3
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/message-formatting.test.ts +197 -2
- package/src/message-formatting.ts +98 -5
- package/src/message-preprocessing.ts +2 -37
- package/src/oauth-rotation-shared.ts +6 -1
- package/src/openai-auth-state.ts +19 -0
- package/src/opencode.ts +132 -111
- package/src/queue-advanced-e2e-setup.ts +78 -29
- package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
- package/src/schema.sql +15 -0
- package/src/schema.ts +20 -0
- package/src/session-handler/event-stream-state.test.ts +490 -0
- package/src/session-handler/event-stream-state.ts +210 -0
- package/src/session-handler/model-utils.ts +1 -0
- package/src/session-handler/thread-runtime-state.ts +0 -4
- package/src/session-handler/thread-session-runtime.ts +108 -69
- package/src/store.ts +10 -0
- package/src/subagent-rate-limit-plugin.ts +12 -2
- package/src/system-message.test.ts +29 -4
- package/src/system-message.ts +20 -4
- package/src/task-runner.ts +168 -11
- package/src/task-schedule.test.ts +172 -2
- package/src/task-schedule.ts +23 -2
- package/src/voice-handler.ts +37 -10
- package/src/xai-auth-plugin.ts +302 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +293 -0
- package/dist/channel-reference-permissions.e2e.test.js +0 -87
- package/dist/commands/add-dir.js +0 -169
- package/dist/commands/add-dir.test.js +0 -126
- package/src/channel-reference-permissions.e2e.test.ts +0 -104
- package/src/commands/add-dir.test.ts +0 -154
- 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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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,
|
|
373
|
-
//
|
|
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.
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
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
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
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
|
-
*
|
|
825
|
-
*
|
|
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
|
-
|
|
833
|
-
|
|
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
|
|
908
|
+
return buildExternalDirectoryPermissionRules({
|
|
909
|
+
resolvedPattern: originalRepo,
|
|
910
|
+
action: 'deny',
|
|
911
|
+
});
|
|
882
912
|
}
|
|
883
913
|
const ALL_EXTERNAL_DIRECTORIES_PATTERN = '*';
|
|
884
|
-
|
|
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
|
|
38
|
-
export const
|
|
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
|
|
269
|
-
id: '
|
|
268
|
+
const externalDirectoryProbeMatcher = {
|
|
269
|
+
id: 'external-directory-probe-marker',
|
|
270
270
|
priority: 106,
|
|
271
271
|
when: {
|
|
272
272
|
lastMessageRole: 'user',
|
|
273
|
-
latestUserTextIncludes: '
|
|
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: '
|
|
278
|
+
{ type: 'text-start', id: 'external-directory-probe-start' },
|
|
279
279
|
{
|
|
280
280
|
type: 'text-delta',
|
|
281
|
-
id: '
|
|
282
|
-
delta: 'reading
|
|
281
|
+
id: 'external-directory-probe-start',
|
|
282
|
+
delta: 'reading external directory',
|
|
283
283
|
},
|
|
284
|
-
{ type: 'text-end', id: '
|
|
284
|
+
{ type: 'text-end', id: 'external-directory-probe-start' },
|
|
285
285
|
{
|
|
286
286
|
type: 'tool-call',
|
|
287
|
-
toolCallId: '
|
|
287
|
+
toolCallId: 'external-directory-read-call',
|
|
288
288
|
toolName: 'read',
|
|
289
|
-
input: JSON.stringify({ filePath:
|
|
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
|
-
|
|
300
|
-
|
|
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: '
|
|
304
|
-
rawPromptIncludes: 'reading
|
|
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: '
|
|
338
|
+
{ type: 'text-start', id: 'external-directory-probe-followup' },
|
|
310
339
|
{
|
|
311
340
|
type: 'text-delta',
|
|
312
|
-
id: '
|
|
313
|
-
delta: '
|
|
341
|
+
id: 'external-directory-probe-followup',
|
|
342
|
+
delta: 'external-directory-probe-done',
|
|
314
343
|
},
|
|
315
|
-
{ type: 'text-end', id: '
|
|
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
|
-
|
|
737
|
-
|
|
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,
|
|
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.
|
|
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(
|
|
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, }) {
|