tokenmaw 0.3.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 (44) hide show
  1. package/README.md +150 -0
  2. package/agents/coordinator.md +13 -0
  3. package/agents/explorer.md +21 -0
  4. package/agents/implement.md +23 -0
  5. package/agents/main.md +25 -0
  6. package/agents/review.md +21 -0
  7. package/dist/backend.js +595 -0
  8. package/dist/cli.js +101 -0
  9. package/dist/config.js +155 -0
  10. package/dist/diff.js +45 -0
  11. package/dist/domain/agent.js +1 -0
  12. package/dist/fetch.js +110 -0
  13. package/dist/infra/file-snapshot.js +54 -0
  14. package/dist/infra/tools.js +1300 -0
  15. package/dist/markdown.js +274 -0
  16. package/dist/model-config.js +48 -0
  17. package/dist/policy.js +80 -0
  18. package/dist/responses.js +81 -0
  19. package/dist/runtime/agent-registry.js +139 -0
  20. package/dist/runtime/agent-runtime.js +993 -0
  21. package/dist/runtime/agent-store.js +152 -0
  22. package/dist/runtime/locks.js +46 -0
  23. package/dist/runtime/session-timeline.js +92 -0
  24. package/dist/tools/index.js +4 -0
  25. package/dist/tools/registry.js +51 -0
  26. package/dist/tools/types.js +1 -0
  27. package/dist/ui/clipboard.js +24 -0
  28. package/dist/ui/commands.js +20 -0
  29. package/dist/ui/composer-layout.js +31 -0
  30. package/dist/ui/fullscreen-tui.js +1405 -0
  31. package/dist/ui/markdown.js +81 -0
  32. package/dist/ui/syntax.js +17 -0
  33. package/dist/ui/tui-design.js +94 -0
  34. package/dist/ui/welcome.js +24 -0
  35. package/dist/version.js +4 -0
  36. package/docs/architecture-revision.md +281 -0
  37. package/package.json +47 -0
  38. package/skills/debugging.md +18 -0
  39. package/skills/git-workflow.md +14 -0
  40. package/skills/node-express.md +27 -0
  41. package/skills/python-flask.md +22 -0
  42. package/skills/react-component.md +24 -0
  43. package/skills/sql-database.md +18 -0
  44. package/skills/testing.md +12 -0
@@ -0,0 +1,152 @@
1
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { resolve } from 'node:path';
5
+ const writes = new Map();
6
+ function validSessionId(sessionId) {
7
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(sessionId)) {
8
+ throw new Error('Invalid session id. Use letters, numbers, dot, underscore, or dash.');
9
+ }
10
+ }
11
+ async function replaceFile(temp, target) {
12
+ let lastError;
13
+ for (let attempt = 0; attempt < 6; attempt += 1) {
14
+ try {
15
+ await rename(temp, target);
16
+ return;
17
+ }
18
+ catch (error) {
19
+ lastError = error;
20
+ const code = error.code;
21
+ if (!['EPERM', 'EEXIST', 'EACCES'].includes(code ?? ''))
22
+ throw error;
23
+ await rm(target, { force: true }).catch(() => undefined);
24
+ await new Promise((resolveWait) => setTimeout(resolveWait, (attempt + 1) * 5));
25
+ }
26
+ }
27
+ throw lastError;
28
+ }
29
+ export class AgentRuntimeStore {
30
+ dir;
31
+ constructor(baseDir = process.env.CODER_DATA_HOME?.trim() || resolve(homedir(), '.coder')) {
32
+ this.dir = resolve(baseDir, 'runtime');
33
+ }
34
+ async init() {
35
+ await mkdir(this.dir, { recursive: true });
36
+ }
37
+ path(sessionId) {
38
+ validSessionId(sessionId);
39
+ return resolve(this.dir, `${sessionId}.json`);
40
+ }
41
+ async save(snapshot) {
42
+ const path = this.path(snapshot.session.sessionId);
43
+ const payload = `${JSON.stringify(snapshot, null, 2)}\n`;
44
+ const key = path.toLowerCase();
45
+ const previous = writes.get(key) ?? Promise.resolve();
46
+ const next = previous.catch(() => undefined).then(async () => {
47
+ await mkdir(this.dir, { recursive: true });
48
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
49
+ try {
50
+ await writeFile(temp, payload, 'utf8');
51
+ await replaceFile(temp, path);
52
+ }
53
+ catch (error) {
54
+ await rm(temp, { force: true }).catch(() => undefined);
55
+ throw error;
56
+ }
57
+ });
58
+ writes.set(key, next);
59
+ try {
60
+ await next;
61
+ }
62
+ finally {
63
+ if (writes.get(key) === next)
64
+ writes.delete(key);
65
+ }
66
+ }
67
+ async load(sessionId) {
68
+ const path = this.path(sessionId);
69
+ await writes.get(path.toLowerCase())?.catch(() => undefined);
70
+ try {
71
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
72
+ return parsed.version === 1 ? parsed : undefined;
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ async list() {
79
+ await Promise.all([...writes.values()].map((write) => write.catch(() => undefined)));
80
+ let files = [];
81
+ try {
82
+ files = await readdir(this.dir);
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ const sessions = [];
88
+ for (const file of files.filter((name) => name.endsWith('.json'))) {
89
+ try {
90
+ const parsed = JSON.parse(await readFile(resolve(this.dir, file), 'utf8'));
91
+ if (parsed.version !== 1)
92
+ continue;
93
+ sessions.push({
94
+ sessionId: parsed.session.sessionId,
95
+ messages: parsed.session.messages.length,
96
+ updatedAt: parsed.session.updatedAt,
97
+ });
98
+ }
99
+ catch { /* skip invalid snapshots */ }
100
+ }
101
+ return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
102
+ }
103
+ async remove(sessionId) {
104
+ const path = this.path(sessionId);
105
+ await writes.get(path.toLowerCase())?.catch(() => undefined);
106
+ await rm(path, { force: true }).catch(() => undefined);
107
+ await this.removeArchives(sessionId);
108
+ }
109
+ // ── Compaction archives ─────────────────────────────────────────────────────
110
+ archivesDir(sessionId) {
111
+ validSessionId(sessionId);
112
+ return resolve(this.dir, 'archives', sessionId);
113
+ }
114
+ /** Persist the messages removed by compaction so agents can search them later. */
115
+ async saveArchive(sessionId, instanceId, seq, messages) {
116
+ const dir = this.archivesDir(sessionId);
117
+ const path = resolve(dir, `${instanceId}.${String(seq).padStart(4, '0')}.json`);
118
+ const payload = `${JSON.stringify({ version: 1, instanceId, seq, messages }, null, 2)}\n`;
119
+ await mkdir(dir, { recursive: true });
120
+ await writeFile(path, payload, 'utf8');
121
+ }
122
+ /** Load archived messages for one instance (or all instances of a session). */
123
+ async loadArchives(sessionId, instanceId) {
124
+ const dir = this.archivesDir(sessionId);
125
+ let files = [];
126
+ try {
127
+ files = await readdir(dir);
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ const archives = [];
133
+ for (const file of files.filter((name) => name.endsWith('.json')).sort()) {
134
+ if (instanceId && !file.startsWith(`${instanceId}.`))
135
+ continue;
136
+ try {
137
+ const parsed = JSON.parse(await readFile(resolve(dir, file), 'utf8'));
138
+ if (parsed.version !== 1 || !Array.isArray(parsed.messages))
139
+ continue;
140
+ archives.push({ instanceId: parsed.instanceId, seq: parsed.seq, messages: parsed.messages });
141
+ }
142
+ catch { /* skip invalid archives */ }
143
+ }
144
+ return archives;
145
+ }
146
+ async removeArchives(sessionId) {
147
+ await rm(this.archivesDir(sessionId), { recursive: true, force: true }).catch(() => undefined);
148
+ }
149
+ async flush() {
150
+ await Promise.all([...writes.values()].map((write) => write.catch(() => undefined)));
151
+ }
152
+ }
@@ -0,0 +1,46 @@
1
+ import { resolve } from 'node:path';
2
+ export class FileLockManager {
3
+ active = new Set();
4
+ waiters = new Map();
5
+ async acquire(path, timeoutMs = 30_000) {
6
+ const key = resolve(path);
7
+ if (!this.active.has(key)) {
8
+ this.active.add(key);
9
+ return () => this.release(key);
10
+ }
11
+ await new Promise((resolveWait, reject) => {
12
+ const queue = this.waiters.get(key) ?? [];
13
+ const waiter = {
14
+ resume: resolveWait,
15
+ reject,
16
+ timer: setTimeout(() => {
17
+ const current = this.waiters.get(key);
18
+ const index = current?.indexOf(waiter) ?? -1;
19
+ if (index >= 0)
20
+ current.splice(index, 1);
21
+ if (current?.length === 0)
22
+ this.waiters.delete(key);
23
+ reject(new Error(`Timed out waiting for write lock: ${key}`));
24
+ }, Math.max(100, timeoutMs)),
25
+ };
26
+ queue.push(waiter);
27
+ this.waiters.set(key, queue);
28
+ });
29
+ this.active.add(key);
30
+ return () => this.release(key);
31
+ }
32
+ release(key) {
33
+ const queue = this.waiters.get(key);
34
+ const next = queue?.shift();
35
+ if (!queue || queue.length === 0) {
36
+ this.waiters.delete(key);
37
+ }
38
+ if (next) {
39
+ clearTimeout(next.timer);
40
+ next.resume();
41
+ }
42
+ else {
43
+ this.active.delete(key);
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const runningEntries = new WeakMap();
3
+ function runningIndex(session, entries) {
4
+ const cached = runningEntries.get(session);
5
+ if (cached?.entries === entries)
6
+ return cached.index;
7
+ const index = new Map();
8
+ for (const entry of entries) {
9
+ if (!entry.instanceId || entry.status !== 'running')
10
+ continue;
11
+ const active = index.get(entry.instanceId) ?? [];
12
+ active.push(entry);
13
+ index.set(entry.instanceId, active);
14
+ }
15
+ runningEntries.set(session, { entries, index });
16
+ return index;
17
+ }
18
+ function finish(entries, predicate, status = 'completed') {
19
+ for (const entry of entries)
20
+ if (predicate(entry))
21
+ entry.status = status;
22
+ }
23
+ /** Record display order at event time, not grouped retrospectively by turn. */
24
+ export function recordTimeline(session, event) {
25
+ const entries = session.timeline ??= session.messages.map(message => ({
26
+ id: message.messageId, kind: 'message', role: message.role,
27
+ turnId: message.turnId, content: message.content, status: 'completed',
28
+ }));
29
+ const index = runningIndex(session, entries);
30
+ if (event.type === 'user_message') {
31
+ if (!entries.some(entry => entry.id === event.message.messageId))
32
+ entries.push({
33
+ id: event.message.messageId, kind: 'message', role: 'user', content: event.message.content,
34
+ turnId: event.message.turnId, status: 'completed',
35
+ });
36
+ return;
37
+ }
38
+ if (!('instanceId' in event) || !event.instanceId) {
39
+ if (event.type === 'instance_updated' && ['idle', 'failed', 'cancelled', 'queued'].includes(event.instance.status)) {
40
+ const active = index.get(event.instance.instanceId) ?? [];
41
+ const status = event.instance.status === 'failed' ? 'failed' : event.instance.status === 'cancelled' || event.instance.status === 'queued' ? 'cancelled' : 'completed';
42
+ finish(active, () => true, status);
43
+ index.delete(event.instance.instanceId);
44
+ }
45
+ return;
46
+ }
47
+ const instanceId = event.instanceId;
48
+ const own = () => index.get(instanceId) ?? [];
49
+ if (event.type === 'thinking_delta' || event.type === 'assistant_delta') {
50
+ const kind = event.type === 'thinking_delta' ? 'thinking' : 'message';
51
+ const active = own();
52
+ let entry = active.at(-1);
53
+ if (!entry || entry.kind !== kind || entry.turnId !== event.turnId) {
54
+ finish(active, previous => previous.kind !== 'tool');
55
+ entry = { id: randomUUID(), kind, instanceId: event.instanceId, turnId: event.turnId,
56
+ role: 'assistant', content: '', status: 'running' };
57
+ entries.push(entry);
58
+ index.set(event.instanceId, [...active.filter(previous => previous.status === 'running'), entry]);
59
+ }
60
+ entry.content += event.text;
61
+ }
62
+ else if (event.type === 'assistant_message') {
63
+ const active = own();
64
+ finish(active, entry => entry.kind !== 'tool');
65
+ const remaining = active.filter(entry => entry.status === 'running');
66
+ if (remaining.length)
67
+ index.set(event.instanceId, remaining);
68
+ else
69
+ index.delete(event.instanceId);
70
+ }
71
+ else if (event.type === 'tool_started') {
72
+ const active = own();
73
+ finish(active, entry => entry.kind !== 'tool');
74
+ const entry = { id: randomUUID(), kind: 'tool', instanceId: event.instanceId,
75
+ turnId: event.turnId, tool: event.tool, input: event.input, content: '', status: 'running' };
76
+ entries.push(entry);
77
+ index.set(event.instanceId, [...active.filter(previous => previous.status === 'running'), entry]);
78
+ }
79
+ else if (event.type === 'tool_finished') {
80
+ const active = own();
81
+ const entry = active.find(entry => entry.kind === 'tool' && entry.tool === event.tool && entry.turnId === event.turnId);
82
+ if (entry) {
83
+ entry.content = event.output;
84
+ entry.status = /^(?:\w*Error:|Error\b)|"ok"\s*:\s*false/.test(event.output) ? 'failed' : 'completed';
85
+ const remaining = active.filter(previous => previous.status === 'running');
86
+ if (remaining.length)
87
+ index.set(event.instanceId, remaining);
88
+ else
89
+ index.delete(event.instanceId);
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,4 @@
1
+ export { ToolRegistry } from './registry.js';
2
+ // Built-ins are exposed through this package boundary for embedding. The
3
+ // legacy infra path remains as a compatibility import for the coordinator.
4
+ export { executeTool, getToolPolicy, listTools, setToolPolicy, toolRegistry, TOOLS, WORKER_TOOLS, } from '../infra/tools.js';
@@ -0,0 +1,51 @@
1
+ /** Small, provider-neutral registry suitable for embedding outside the agent. */
2
+ export class ToolRegistry {
3
+ #tools = new Map();
4
+ register(tool) {
5
+ const name = tool.definition.function.name.trim();
6
+ if (!name)
7
+ throw new Error('Tool name cannot be empty');
8
+ if (this.#tools.has(name))
9
+ throw new Error(`Tool already registered: ${name}`);
10
+ if (tool.definition.function.parameters.type !== 'object') {
11
+ throw new Error(`Tool parameters must be an object schema: ${name}`);
12
+ }
13
+ this.#tools.set(name, tool);
14
+ return this;
15
+ }
16
+ has(name) {
17
+ return this.#tools.has(name);
18
+ }
19
+ get(name) {
20
+ return this.#tools.get(name);
21
+ }
22
+ definitions(options = {}) {
23
+ return [...this.#tools.values()]
24
+ .filter((tool) => options.includeHidden || !tool.metadata.hidden)
25
+ .map((tool) => tool.definition);
26
+ }
27
+ describe(options = {}) {
28
+ return [...this.#tools.values()]
29
+ .filter((tool) => options.includeHidden || !tool.metadata.hidden)
30
+ .map((tool) => ({
31
+ name: tool.definition.function.name,
32
+ description: tool.definition.function.description,
33
+ metadata: { ...tool.metadata },
34
+ }));
35
+ }
36
+ async execute(name, args, context) {
37
+ const tool = this.#tools.get(name);
38
+ if (!tool)
39
+ return `Error: unknown tool "${name}"`;
40
+ if (context?.signal?.aborted)
41
+ return 'Error: tool execution aborted';
42
+ try {
43
+ return await tool.execute(args, context);
44
+ }
45
+ catch (error) {
46
+ if (context?.signal?.aborted)
47
+ return 'Error: tool execution aborted';
48
+ return `Error executing ${name}: ${error instanceof Error ? error.message : String(error)}`;
49
+ }
50
+ }
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { spawn } from 'node:child_process';
2
+ /** Clipboard payload travels over stdin, never through shell interpolation. */
3
+ export function copyText(text) {
4
+ const command = process.platform === 'win32' ? 'powershell.exe' : process.platform === 'darwin' ? 'pbcopy' : process.env.WAYLAND_DISPLAY ? 'wl-copy' : 'xclip';
5
+ const args = process.platform === 'win32'
6
+ ? ['-NoProfile', '-NonInteractive', '-Command', '[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())']
7
+ : command === 'xclip' ? ['-selection', 'clipboard'] : [];
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn(command, args, { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] });
10
+ let error = '';
11
+ const timer = setTimeout(() => { child.kill(); reject(new Error('Clipboard operation timed out')); }, 5000);
12
+ child.stderr.on('data', (chunk) => { error += chunk.toString(); });
13
+ child.on('error', (cause) => { clearTimeout(timer); reject(cause); });
14
+ child.stdin.on('error', () => { });
15
+ child.on('close', (code) => {
16
+ clearTimeout(timer);
17
+ if (code === 0)
18
+ resolve();
19
+ else
20
+ reject(new Error(error.trim() || 'Clipboard unavailable'));
21
+ });
22
+ child.stdin.end(text, 'utf8');
23
+ });
24
+ }
@@ -0,0 +1,20 @@
1
+ export const SLASH_COMMANDS = [
2
+ { name: '/provider', description: 'Manage providers' },
3
+ { name: '/model', description: 'Choose a model' },
4
+ { name: '/agents', description: 'Inspect agent specs' },
5
+ { name: '/sessions', description: 'Open a saved conversation' },
6
+ { name: '/new', description: 'Start a conversation' },
7
+ { name: '/clear', description: 'Clear this conversation' },
8
+ { name: '/compact', description: 'Summarize and archive older context' },
9
+ { name: '/cancel', description: 'Stop current work' },
10
+ { name: '/select', description: 'Native terminal selection' },
11
+ { name: '/mouse', description: 'Toggle app mouse interaction' },
12
+ { name: '/help', description: 'Open command palette' },
13
+ { name: '/exit', description: 'Exit TokenMaw' },
14
+ { name: '/quit', description: 'Exit TokenMaw' },
15
+ ];
16
+ export function commandMatches(input) {
17
+ if (!/^\/[^\s]*$/.test(input))
18
+ return [];
19
+ return SLASH_COMMANDS.filter((command) => command.name.startsWith(input.toLowerCase()));
20
+ }
@@ -0,0 +1,31 @@
1
+ /** Map code-point cursor positions to terminal cells, including wrapped CJK text. */
2
+ export function layoutComposer(value, cursor, width, measure) {
3
+ const chars = Array.from(value);
4
+ const rows = [''];
5
+ const positions = [];
6
+ let row = 0;
7
+ let column = 0;
8
+ width = Math.max(2, width);
9
+ for (let index = 0; index <= chars.length; index++) {
10
+ const char = chars[index];
11
+ const cells = char && char !== '\n' ? measure(char) : 0;
12
+ if (column >= width || (cells > 0 && column + cells > width)) {
13
+ rows.push('');
14
+ row++;
15
+ column = 0;
16
+ }
17
+ positions.push({ row, column });
18
+ if (char === undefined)
19
+ break;
20
+ if (char === '\n') {
21
+ rows.push('');
22
+ row++;
23
+ column = 0;
24
+ }
25
+ else {
26
+ rows[row] += char;
27
+ column += cells;
28
+ }
29
+ }
30
+ return { rows, cursor: positions[Math.max(0, Math.min(cursor, chars.length))] };
31
+ }