kodenique-agent 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -16
- package/agent.mjs +6 -4
- package/cli.mjs +44 -64
- package/commands.mjs +1 -0
- package/package.json +2 -1
- package/render.mjs +94 -0
- package/ui.mjs +88 -16
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ kodenique-agent
|
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Update with `npm install -g kodenique-agent@latest`.
|
|
17
|
-
A release archive can also be installed with `npm install -g ./kodenique-agent-0.
|
|
17
|
+
A release archive can also be installed with `npm install -g ./kodenique-agent-0.8.0.tgz`.
|
|
18
18
|
|
|
19
19
|
The wizard asks for their API key (hidden while typing), optional Anthropic workspace ID, model from the live Claude API list, and whether to enable thinking summaries. Use a separate key per teammate; don't include your personal key in the package.
|
|
20
20
|
|
|
@@ -39,20 +39,33 @@ node /path/to/kodenique-agent/cli.mjs /path/to/project
|
|
|
39
39
|
## What it looks like
|
|
40
40
|
|
|
41
41
|
```text
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
42
|
+
> Find and fix the failing test
|
|
43
|
+
|
|
44
|
+
✻ Working… (2s · esc to interrupt)
|
|
45
|
+
|
|
46
|
+
⏺ Bash(npm test)
|
|
47
|
+
⎿ Exit code 1
|
|
48
|
+
✗ parses dates
|
|
49
|
+
|
|
50
|
+
⏺ Read(src/date.ts)
|
|
51
|
+
⎿ Read 42 lines
|
|
52
|
+
|
|
53
|
+
⏺ Write(src/date.ts)
|
|
54
|
+
@@ line 12 @@
|
|
55
|
+
- return d.getDay();
|
|
56
|
+
+ return d.getDate();
|
|
57
|
+
|
|
58
|
+
Do you want to make this edit to src/date.ts?
|
|
59
|
+
❯ 1. Yes
|
|
60
|
+
2. Yes, and auto-approve edits this session
|
|
61
|
+
3. No
|
|
62
|
+
|
|
63
|
+
⏺ Fixed: getDay() returned the weekday, not the day of month.
|
|
64
|
+
⎿ Done · 9.4s · session tokens 5120 in / 640 out
|
|
54
65
|
```
|
|
55
66
|
|
|
67
|
+
Keys: **Esc** interrupts the current turn. The partial turn is removed from context and nothing more runs, but a shell command that already started finishes. **Shift+Tab** at the prompt cycles approval modes (ask → auto-edit → auto-all). Replies can use up to 16,000 output tokens (32,000 with thinking).
|
|
68
|
+
|
|
56
69
|
Thinking is optional. Enable with `/thinking on`, disable with `/thinking off`. It requests adaptive thinking with summarized display; use a compatible model such as Sonnet 4.6. Summaries are exactly what the API returns, not full private reasoning. Claude can skip thinking for simple prompts. Thinking can consume additional tokens, and the app makes no fixed-cost guarantee.
|
|
57
70
|
|
|
58
71
|
## Chat commands
|
|
@@ -69,12 +82,13 @@ Thinking is optional. Enable with `/thinking on`, disable with `/thinking off`.
|
|
|
69
82
|
| `/project` | Prompt for a local project path |
|
|
70
83
|
| `/files` | List the current project directory locally |
|
|
71
84
|
| `/help` | Show help |
|
|
85
|
+
| `/compact` | Replace the conversation with a model-written summary to free up context |
|
|
72
86
|
| `/clear` | Reset conversation context |
|
|
73
87
|
| `/thinking on` or `/thinking off` | Toggle thinking for this session |
|
|
74
88
|
| `/usage` | Show reported session input/output tokens |
|
|
75
89
|
| `/exit` or Ctrl+C | Exit |
|
|
76
90
|
|
|
77
|
-
The agent lists and reads files, proposes full-file edits, and runs commands such as searches and tests. In Ask mode, every write shows a colored change preview and
|
|
91
|
+
The agent lists and reads files, proposes full-file edits, and runs commands such as searches and tests. In Ask mode, every write shows a colored change preview and every command shows its exact text. Pick 1 (yes), 2 (yes, and auto-approve that kind of action for this session) or 3 / Enter (no). Use `/full` at an approval prompt to see the complete change. Tool activity is visible. It never runs an incomplete streamed tool request.
|
|
78
92
|
|
|
79
93
|
## Boundaries
|
|
80
94
|
|
|
@@ -82,7 +96,7 @@ Approved shell commands have your full local permissions; they are **not sandbox
|
|
|
82
96
|
|
|
83
97
|
File tools reject traversal outside the project, symlinks, `.git`, `.ssh`, `.aws`, `.env*`, `node_modules`, `.pem` and `.key` files. This is not a complete secret detector or protection against malicious concurrent filesystem changes. Prompts and tool results are sent directly to Anthropic. File and command output limits are 100 KB; directory listings stop at 500 entries.
|
|
84
98
|
|
|
85
|
-
Conversations live in memory only. There is no saved history
|
|
99
|
+
Conversations live in memory only. There is no saved history and no dollar spending cap. `/compact` summarizes manually; there is no automatic compaction. `/clear` reduces growing context. The loop stops after 15 model calls per user turn. Shell commands time out after 60 seconds; API calls after 120 seconds. Ctrl+C exits the agent; already started shell descendants may require separate cleanup.
|
|
86
100
|
|
|
87
101
|
## Build a team release
|
|
88
102
|
|
|
@@ -98,7 +112,7 @@ The package allowlist contains only the CLI, agent, stream parser, config and mo
|
|
|
98
112
|
|
|
99
113
|
## Validation and protocol
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
Fourteen tests cover file restrictions and approvals, concurrent edits, real local HTTP request/response and errors, workspace headers, fragmented streamed thinking/text/tool JSON, signature preservation, interrupted streams, private config/environment overrides, paginated model discovery/selection, approval mode enforcement, UI previews, and local project selection/completion. They do not establish live model availability or account billing. No live Claude request was made for this release.
|
|
102
116
|
|
|
103
117
|
API contract: POST `https://api.anthropic.com/v1/messages`, `x-api-key`, `anthropic-version: 2023-06-01`, optional `anthropic-workspace-id`. Streaming uses SSE. Thinking blocks/signatures are preserved in conversation history, followed by all matching tool results.
|
|
104
118
|
|
package/agent.mjs
CHANGED
|
@@ -59,12 +59,12 @@ export async function executeTool(root, tool, approve) {
|
|
|
59
59
|
await fs.writeFile(target,input.content,'utf8');
|
|
60
60
|
return `Wrote ${input.path}`;
|
|
61
61
|
}
|
|
62
|
-
export async function apiRequest({key,workspaceId,baseUrl='https://api.anthropic.com',timeout=120000}, endpoint, body) {
|
|
62
|
+
export async function apiRequest({key,workspaceId,baseUrl='https://api.anthropic.com',timeout=120000,signal}, endpoint, body) {
|
|
63
63
|
const url=new URL(baseUrl);
|
|
64
64
|
if(url.origin!=='https://api.anthropic.com' && !(['localhost','127.0.0.1','[::1]'].includes(url.hostname) && url.protocol==='http:')) throw Error('Only Anthropic or a local HTTP test server is allowed');
|
|
65
65
|
const headers={'content-type':'application/json','x-api-key':key,'anthropic-version':'2023-06-01'};
|
|
66
66
|
if(workspaceId?.trim()) headers['anthropic-workspace-id']=workspaceId.trim();
|
|
67
|
-
const response=await fetch(`${baseUrl.replace(/\/$/,'')}${endpoint}`,{method:body?'POST':'GET',headers,...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(timeout),redirect:'error'});
|
|
67
|
+
const response=await fetch(`${baseUrl.replace(/\/$/,'')}${endpoint}`,{method:body?'POST':'GET',headers,...(body?{body:JSON.stringify(body)}:{}),signal:signal?AbortSignal.any([signal,AbortSignal.timeout(timeout)]):AbortSignal.timeout(timeout),redirect:'error'});
|
|
68
68
|
if(!response.ok) {
|
|
69
69
|
let detail='';
|
|
70
70
|
try {
|
|
@@ -82,13 +82,14 @@ export async function apiRequest({key,workspaceId,baseUrl='https://api.anthropic
|
|
|
82
82
|
return response;
|
|
83
83
|
}
|
|
84
84
|
export async function requestMessage({model,messages,stream=false,thinking=false,onEvent,...config}) {
|
|
85
|
-
const response=await apiRequest(config,'/v1/messages',{model,max_tokens:thinking?
|
|
85
|
+
const response=await apiRequest(config,'/v1/messages',{model,max_tokens:thinking?32000:16000,system,tools,messages,...(stream?{stream:true}:{}),...(thinking?{thinking:{type:'adaptive',display:'summarized'}}:{})});
|
|
86
86
|
const result=stream ? await readMessageStream(response.body,onEvent) : await response.json();
|
|
87
87
|
if(!Array.isArray(result.content)) throw Error('Invalid API response');
|
|
88
88
|
return result;
|
|
89
89
|
}
|
|
90
|
-
export async function runTurn({messages,request,execute,output=()=>{},onUsage=()=>{},streamed=false,maxSteps=15}) {
|
|
90
|
+
export async function runTurn({messages,request,execute,output=()=>{},onUsage=()=>{},streamed=false,maxSteps=15,signal}) {
|
|
91
91
|
for(let step=0;step<maxSteps;step++) {
|
|
92
|
+
signal?.throwIfAborted();
|
|
92
93
|
const response=await request(messages);
|
|
93
94
|
onUsage(response.usage || {});
|
|
94
95
|
// Never execute a possibly incomplete tool request.
|
|
@@ -99,6 +100,7 @@ export async function runTurn({messages,request,execute,output=()=>{},onUsage=()
|
|
|
99
100
|
if(!calls.length) return;
|
|
100
101
|
const results=[];
|
|
101
102
|
for(const call of calls) {
|
|
103
|
+
signal?.throwIfAborted();
|
|
102
104
|
try { results.push({type:'tool_result',tool_use_id:call.id,content:String(await execute(call))}); }
|
|
103
105
|
catch(e) {
|
|
104
106
|
if(e.code==='ANTHROPIC_RATE_LIMIT') throw e;
|
package/cli.mjs
CHANGED
|
@@ -3,17 +3,19 @@ import { createInterface } from 'node:readline/promises';
|
|
|
3
3
|
import { Writable } from 'node:stream';
|
|
4
4
|
import {commands,completeCommand,resolveProject} from './commands.mjs';
|
|
5
5
|
import { executeTool, printable, requestMessage, runTurn } from './agent.mjs';
|
|
6
|
-
import {createUI,parseArgs,
|
|
6
|
+
import {createUI,parseArgs,approvalModes,nextMode,VERSION} from './ui.mjs';
|
|
7
|
+
import {turnHandlers} from './render.mjs';
|
|
7
8
|
import { pickModel } from './models.mjs';
|
|
8
9
|
import { configPath, loadConfig, saveConfig, resolveConfig } from './config.mjs';
|
|
9
10
|
|
|
10
11
|
const args=process.argv.slice(2);
|
|
11
|
-
const help=`kodenique-agent [project-folder] [--approval ask|auto-edit|auto-all]\nkodenique-agent --auto Auto-approve edits and commands for this session\nkodenique-agent setup Configure your own API key and workspace\nkodenique-agent --version\n\nChat: / /project [folder] /files /approval /status /model /models /help /clear /thinking on|off /usage /exit\nEnvironment overrides: ANTHROPIC_API_KEY, ANTHROPIC_WORKSPACE_ID,\nANTHROPIC_MODEL, ANTHROPIC_THINKING=on|off\nRequires Node.js 22+, macOS or Linux. Ask is the default. Auto-all runs shell commands with your full permissions.`;
|
|
12
|
+
const help=`kodenique-agent [project-folder] [--approval ask|auto-edit|auto-all]\nkodenique-agent --auto Auto-approve edits and commands for this session\nkodenique-agent setup Configure your own API key and workspace\nkodenique-agent --version\n\nChat: / /project [folder] /files /approval /status /model /models /help /compact /clear /thinking on|off /usage /exit\nEnvironment overrides: ANTHROPIC_API_KEY, ANTHROPIC_WORKSPACE_ID,\nANTHROPIC_MODEL, ANTHROPIC_THINKING=on|off\nRequires Node.js 22+, macOS or Linux. Ask is the default. Auto-all runs shell commands with your full permissions.`;
|
|
12
13
|
if(args.includes('--help')) { console.log(help); process.exit(0); }
|
|
13
|
-
if(args.includes('--version')) { console.log(
|
|
14
|
+
if(args.includes('--version')) { console.log(`kodenique-agent ${VERSION}`); process.exit(0); }
|
|
14
15
|
let options;
|
|
15
16
|
try { options=parseArgs(args); } catch(e) {console.error(e.message);process.exit(1);}
|
|
16
|
-
|
|
17
|
+
const state={approval:options.mode,approving:false};
|
|
18
|
+
let turn=null;
|
|
17
19
|
if(!process.stdin.isTTY) { console.error('Run in an interactive terminal.'); process.exit(1); }
|
|
18
20
|
let muted=false;
|
|
19
21
|
const terminalOutput=new Writable({write(chunk,encoding,done){ if(!muted) process.stdout.write(chunk); done(); }});
|
|
@@ -32,7 +34,13 @@ const show=value=>console.log(clean(value));
|
|
|
32
34
|
const ui=createUI(clean);
|
|
33
35
|
const commandMenu=()=>{ui.label('Commands · type a command, Tab completes');for(const [command,description] of commands) show(` ${command.padEnd(16)} ${description}`);};
|
|
34
36
|
let chatPrompt=false;
|
|
35
|
-
|
|
37
|
+
let frame=null;
|
|
38
|
+
process.stdin.on('keypress',(text,key)=>{
|
|
39
|
+
if(key?.name==='escape' && turn && !state.approving) turn.abort();
|
|
40
|
+
if(chatPrompt && frame && key?.name==='tab' && key.shift) {
|
|
41
|
+
state.approval=nextMode(state.approval);
|
|
42
|
+
process.stdout.write('\x1b7\x1b[1A\r\x1b[2K'+ui.modeLine({...frame,mode:state.approval})+'\x1b8');
|
|
43
|
+
}
|
|
36
44
|
if(chatPrompt && text==='/' && rl.line==='/') {
|
|
37
45
|
process.stdout.write('\n'); commandMenu(); rl.prompt(true);
|
|
38
46
|
}
|
|
@@ -54,13 +62,14 @@ try {
|
|
|
54
62
|
}
|
|
55
63
|
if(options.workspace!=='setup') {
|
|
56
64
|
let root=await resolveProject(options.workspace||process.cwd());
|
|
57
|
-
ui.header({model:config.model,root,mode:approval,thinking:config.thinking});
|
|
58
|
-
if(approval==='auto-all') ui.note('Auto-all: edits and shell commands run without prompts, with full local permissions.');
|
|
65
|
+
ui.header({model:config.model,root,mode:state.approval,thinking:config.thinking});
|
|
66
|
+
if(state.approval==='auto-all') ui.note('Auto-all: edits and shell commands run without prompts, with full local permissions.');
|
|
59
67
|
let messages=[]; let usage={input:0,output:0};
|
|
60
68
|
while(!rl.closed) {
|
|
61
69
|
chatPrompt=true;
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
frame={model:config.model,root,thinking:config.thinking};
|
|
71
|
+
ui.inputFrame({...frame,mode:state.approval});
|
|
72
|
+
const prompt=await ask(ui.prompt(state.approval));
|
|
64
73
|
chatPrompt=false;
|
|
65
74
|
if(!prompt) continue;
|
|
66
75
|
if(prompt==='/exit') break;
|
|
@@ -71,7 +80,7 @@ try {
|
|
|
71
80
|
try {
|
|
72
81
|
const next=await resolveProject(input,root);
|
|
73
82
|
if(next!==root) {root=next;messages=[];ui.success('Project opened. Conversation cleared.');}
|
|
74
|
-
ui.header({model:config.model,root,mode:approval,thinking:config.thinking});
|
|
83
|
+
ui.header({model:config.model,root,mode:state.approval,thinking:config.thinking});
|
|
75
84
|
} catch(e) {ui.error(`Cannot open project: ${e.message}. Current project unchanged.`);}
|
|
76
85
|
continue;
|
|
77
86
|
}
|
|
@@ -93,76 +102,47 @@ try {
|
|
|
93
102
|
}
|
|
94
103
|
continue;
|
|
95
104
|
}
|
|
96
|
-
if(prompt==='/status') {ui.header({model:config.model,root,mode:approval,thinking:config.thinking});continue;}
|
|
105
|
+
if(prompt==='/status') {ui.header({model:config.model,root,mode:state.approval,thinking:config.thinking});continue;}
|
|
97
106
|
if(prompt==='/approval' || prompt.startsWith('/approval ')) {
|
|
98
|
-
const next=prompt==='/approval'?await ask(`Approval mode: ask / auto-edit / auto-all [${approval}]: `):prompt.slice(10).trim();
|
|
107
|
+
const next=prompt==='/approval'?await ask(`Approval mode: ask / auto-edit / auto-all [${state.approval}]: `):prompt.slice(10).trim();
|
|
99
108
|
if(!next) continue;
|
|
100
109
|
if(!approvalModes.includes(next)) {ui.error('Choose ask, auto-edit, or auto-all.');continue;}
|
|
101
|
-
approval=next;ui.success(`Approval mode: ${approval} (this session only)`);
|
|
102
|
-
if(approval==='auto-all') ui.note('Edits and shell commands run without prompts, with full local permissions.');
|
|
110
|
+
state.approval=next;ui.success(`Approval mode: ${state.approval} (this session only)`);
|
|
111
|
+
if(state.approval==='auto-all') ui.note('Edits and shell commands run without prompts, with full local permissions.');
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if(prompt==='/compact') {
|
|
115
|
+
if(!messages.length) {ui.note('Nothing to compact.');continue;}
|
|
116
|
+
ui.startSpinner();
|
|
117
|
+
try {
|
|
118
|
+
const r=await requestMessage({...config,thinking:false,messages:[...messages,{role:'user',content:'Summarize this conversation for continuing the work: goals, decisions, files read or changed, current state, next steps. Reply with the summary only; do not call tools.'}]});
|
|
119
|
+
usage.input+=r.usage?.input_tokens||0;usage.output+=r.usage?.output_tokens||0;
|
|
120
|
+
const summary=r.content.filter(b=>b.type==='text').map(b=>b.text).join('\n').trim();
|
|
121
|
+
if(!summary) throw Error('empty summary');
|
|
122
|
+
messages=[{role:'user',content:'Summary of our earlier conversation:\n'+summary},{role:'assistant',content:'Understood. Continuing from that context.'}];
|
|
123
|
+
ui.stopSpinner();ui.done('Conversation compacted (/clear starts fresh instead)');
|
|
124
|
+
} catch(e) {ui.stopSpinner();ui.error(`Compact failed: ${e.message}. Conversation unchanged.`);}
|
|
103
125
|
continue;
|
|
104
126
|
}
|
|
105
127
|
if(prompt==='/usage') { show(`Session usage: ${usage.input} input / ${usage.output} output tokens (reported by API).`); continue; }
|
|
106
128
|
if(prompt==='/thinking on' || prompt==='/thinking off') { config.thinking=prompt.endsWith(' on'); show(`Thinking summaries ${config.thinking?'on':'off'} for this session.`); continue; }
|
|
107
129
|
if(prompt.startsWith('/')) { show('Unknown command. Use /help.'); continue; }
|
|
130
|
+
const before=messages.length;
|
|
108
131
|
messages.push({role:'user',content:prompt});
|
|
109
132
|
const start=Date.now();
|
|
133
|
+
turn=new AbortController();
|
|
110
134
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const flush=(text='',final=false)=>{
|
|
115
|
-
pending+=text; pending=pending.split(config.key).join('[REDACTED]');
|
|
116
|
-
let hold=0;
|
|
117
|
-
if(!final) for(let n=1;n<Math.min(config.key.length,pending.length+1);n++) if(pending.endsWith(config.key.slice(0,n))) hold=n;
|
|
118
|
-
process.stdout.write(printable(pending.slice(0,pending.length-hold)));
|
|
119
|
-
pending=hold?pending.slice(-hold):'';
|
|
120
|
-
};
|
|
121
|
-
const finish=()=>{flush('',true); if(line) {console.log();line=false;} };
|
|
122
|
-
const began=Date.now();
|
|
123
|
-
process.stdout.write('Working…');
|
|
124
|
-
let waiting=true;
|
|
125
|
-
const stopWait=()=>{if(waiting){process.stdout.write('\r\x1b[2K');waiting=false;}};
|
|
126
|
-
const timer=setInterval(()=>{if(waiting) process.stdout.write(`\rWorking… ${Math.floor((Date.now()-began)/1000)}s`);},1000);
|
|
127
|
-
try {
|
|
128
|
-
return await requestMessage({...config,messages:history,stream:true,onEvent:event=>{
|
|
129
|
-
stopWait();
|
|
130
|
-
if(event.type==='block_start') {
|
|
131
|
-
finish();
|
|
132
|
-
const type=event.block.type;
|
|
133
|
-
if(type==='thinking') {ui.label('◌ Thinking summary');line=true;}
|
|
134
|
-
if(type==='redacted_thinking') show('Thinking… (content not provided by API)');
|
|
135
|
-
if(type==='text') {ui.label('◇ Claude');line=true;}
|
|
136
|
-
if(type==='tool_use') show(`Preparing ${event.block.name}…`);
|
|
137
|
-
}
|
|
138
|
-
if(event.type==='text' || event.type==='thinking') flush(event.text);
|
|
139
|
-
if(event.type==='block_stop') finish();
|
|
140
|
-
}});
|
|
141
|
-
} finally {clearInterval(timer);stopWait();finish();}
|
|
142
|
-
},execute:async call=>{
|
|
143
|
-
ui.label(`› ${call.name}${call.input?.path?' · '+call.input.path:''}`);
|
|
144
|
-
try {
|
|
145
|
-
const result=await executeTool(root,call,async (description,action)=>{
|
|
146
|
-
if(action.tool==='write_file') {ui.note(`Edit · ${action.path}`);ui.diff(editPreview(action.before,action.after));}
|
|
147
|
-
else ui.note(`$ ${action.command}`);
|
|
148
|
-
if(autoApproves(approval,action.tool)) {ui.note(`Auto-approved · ${approval}`);return true;}
|
|
149
|
-
while(true) {
|
|
150
|
-
const answer=(await ask('Allow? [y/N] · /full shows complete change: ')).toLowerCase();
|
|
151
|
-
if(answer==='/full') {show(description);continue;}
|
|
152
|
-
return answer==='y';
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
ui.success('✓ Completed'); return result;
|
|
156
|
-
} catch(e) {ui.error(`✗ ${e.message}`);throw e;}
|
|
157
|
-
}});
|
|
158
|
-
ui.note(`Done · ${((Date.now()-start)/1000).toFixed(1)}s · session tokens ${usage.input} in / ${usage.output} out`);
|
|
135
|
+
const {request,execute}=turnHandlers({ui,config,root,ask,show,state,signal:turn.signal});
|
|
136
|
+
await runTurn({messages,streamed:true,signal:turn.signal,output:show,onUsage:u=>{usage.input+=u.input_tokens||0;usage.output+=u.output_tokens||0;},request,execute});
|
|
137
|
+
ui.done(`Done · ${((Date.now()-start)/1000).toFixed(1)}s · session tokens ${usage.input} in / ${usage.output} out`);
|
|
159
138
|
} catch(e) {
|
|
160
|
-
if(
|
|
139
|
+
if(turn?.signal.aborted) {messages.length=before;ui.interrupted();ui.note(' Conversation rolled back to before this message.');}
|
|
140
|
+
else if(e.code==='ANTHROPIC_RATE_LIMIT') {
|
|
161
141
|
ui.error('Claude API limit reached. This request stopped; kodenique-agent will not retry or send the failed tool back to Claude.');
|
|
162
142
|
show(e.message);
|
|
163
143
|
if(messages.at(-1)?.role==='user') messages.pop();
|
|
164
144
|
} else show(`Error: ${e.message}\nUse /clear to reset, or try again. For unsupported thinking, use /thinking off.`);
|
|
165
|
-
}
|
|
145
|
+
} finally {turn=null;}
|
|
166
146
|
}
|
|
167
147
|
}
|
|
168
148
|
} catch(e) { show(`Error: ${e.message}`); process.exitCode=1; }
|
package/commands.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export const commands=[
|
|
|
11
11
|
['/thinking off','Disable thinking summaries'],
|
|
12
12
|
['/status','Show project, model, and approval mode'],
|
|
13
13
|
['/usage','Show session token usage'],
|
|
14
|
+
['/compact','Summarize the conversation to free up context'],
|
|
14
15
|
['/clear','Start a fresh conversation'],
|
|
15
16
|
['/help','Show startup options and commands'],
|
|
16
17
|
['/exit','Close kodenique-agent'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodenique-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Your team's terminal coding agent, powered directly by the Claude API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"config.mjs",
|
|
21
21
|
"models.mjs",
|
|
22
22
|
"ui.mjs",
|
|
23
|
+
"render.mjs",
|
|
23
24
|
"commands.mjs",
|
|
24
25
|
"README.md"
|
|
25
26
|
],
|
package/render.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { executeTool, printable, requestMessage } from './agent.mjs';
|
|
2
|
+
import { autoApproves, editPreview } from './ui.mjs';
|
|
3
|
+
|
|
4
|
+
// Short, Claude Code–style "⎿" summaries of tool results.
|
|
5
|
+
export function summarize(call, result) {
|
|
6
|
+
const text = String(result ?? '');
|
|
7
|
+
const count = s => (s ? s.replace(/\n$/, '').split('\n').length : 0);
|
|
8
|
+
if (call.name === 'read_file') return [`Read ${count(text)} lines`];
|
|
9
|
+
if (call.name === 'list_files') return [`Listed ${count(text)} entries`];
|
|
10
|
+
if (call.name === 'write_file') return [text];
|
|
11
|
+
if (call.name === 'run_command') {
|
|
12
|
+
let r; try { r = JSON.parse(text); } catch { return [text.slice(0, 200)]; }
|
|
13
|
+
const out = [r.stdout, r.stderr].filter(Boolean).join('\n').replace(/\n$/, '');
|
|
14
|
+
const rows = out ? out.split('\n') : [];
|
|
15
|
+
const head = rows.slice(0, 4);
|
|
16
|
+
if (rows.length > 4) head.push(`… +${rows.length - 4} lines`);
|
|
17
|
+
if (r.exitCode) head.unshift(`Exit code ${r.exitCode}`);
|
|
18
|
+
return head.length ? head : ['(no output)'];
|
|
19
|
+
}
|
|
20
|
+
return [text.slice(0, 200)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function turnHandlers({ ui, config, root, ask, show, state, signal, output = process.stdout }) {
|
|
24
|
+
const request = async history => {
|
|
25
|
+
let open = false, mode = null, pending = '';
|
|
26
|
+
const key = config.key || '\0';
|
|
27
|
+
const style = s => (s && mode === 'thinking' ? ui.paint('2;3', s) : s);
|
|
28
|
+
// Hold only a suffix that could be the beginning of the API key.
|
|
29
|
+
const flush = (text = '', final = false) => {
|
|
30
|
+
pending = (pending + text).split(key).join('[REDACTED]');
|
|
31
|
+
let hold = 0;
|
|
32
|
+
if (!final) for (let n = 1; n < Math.min(key.length, pending.length + 1); n++) if (pending.endsWith(key.slice(0, n))) hold = n;
|
|
33
|
+
output.write(style(printable(pending.slice(0, pending.length - hold))));
|
|
34
|
+
pending = hold ? pending.slice(-hold) : '';
|
|
35
|
+
};
|
|
36
|
+
const finish = () => { flush('', true); if (open) { output.write('\n'); open = false; mode = null; } };
|
|
37
|
+
let spinning = true;
|
|
38
|
+
const stop = () => { if (spinning) { ui.stopSpinner(); spinning = false; } };
|
|
39
|
+
ui.startSpinner();
|
|
40
|
+
try {
|
|
41
|
+
return await requestMessage({ ...config, messages: history, stream: true, signal, onEvent: event => {
|
|
42
|
+
stop();
|
|
43
|
+
if (event.type === 'block_start') {
|
|
44
|
+
finish();
|
|
45
|
+
const type = event.block.type;
|
|
46
|
+
if (type === 'thinking') { output.write('\n'); ui.thinking(); output.write('\n '); open = true; mode = 'thinking'; }
|
|
47
|
+
if (type === 'redacted_thinking') ui.note('✻ Thinking… (redacted by API)');
|
|
48
|
+
if (type === 'text') { output.write('\n'); ui.assistant(); open = true; mode = 'text'; }
|
|
49
|
+
}
|
|
50
|
+
if (event.type === 'text' || event.type === 'thinking') flush(event.text);
|
|
51
|
+
if (event.type === 'block_stop') finish();
|
|
52
|
+
} });
|
|
53
|
+
} finally { stop(); finish(); }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const approve = async (description, action) => {
|
|
57
|
+
const write = action.tool === 'write_file';
|
|
58
|
+
if (write) ui.diff(editPreview(action.before, action.after));
|
|
59
|
+
else ui.note(' $ ' + action.command);
|
|
60
|
+
if (autoApproves(state.approval, action.tool)) { ui.done(`Auto-approved (${state.approval})`); return true; }
|
|
61
|
+
ui.approvalMenu({
|
|
62
|
+
question: write ? `Do you want to ${action.before == null ? 'create' : 'make this edit to'} ${action.path}?` : 'Do you want to run this command?',
|
|
63
|
+
optionTwo: write ? 'Yes, and auto-approve edits this session' : "Yes, and don't ask again for commands this session",
|
|
64
|
+
});
|
|
65
|
+
state.approving = true;
|
|
66
|
+
try {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const a = String((await ask(' ❯ ')) ?? '').trim().toLowerCase();
|
|
69
|
+
if (a === '/full') { show(description); continue; }
|
|
70
|
+
if (['1', 'y', 'yes'].includes(a)) return true;
|
|
71
|
+
if (a === '2') {
|
|
72
|
+
state.approval = write && state.approval !== 'auto-all' ? 'auto-edit' : 'auto-all';
|
|
73
|
+
ui.done(`Approval mode: ${state.approval} (this session)`);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
} finally { state.approving = false; }
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const execute = async call => {
|
|
82
|
+
output.write('\n');
|
|
83
|
+
ui.tool(call);
|
|
84
|
+
try {
|
|
85
|
+
const result = await executeTool(root, call, approve);
|
|
86
|
+
const [first, ...rest] = summarize(call, result);
|
|
87
|
+
ui.result(first);
|
|
88
|
+
for (const row of rest) ui.note(' ' + row);
|
|
89
|
+
return result;
|
|
90
|
+
} catch (e) { ui.result(e.message, false); throw e; }
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return { request, execute };
|
|
94
|
+
}
|
package/ui.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
export const VERSION='0.8.0';
|
|
3
|
+
export const nextMode=mode=>approvalModes[(approvalModes.indexOf(mode)+1)%approvalModes.length];
|
|
1
4
|
export const approvalModes=['ask','auto-edit','auto-all'];
|
|
2
5
|
export function autoApproves(mode,tool) {
|
|
3
6
|
return mode==='auto-all' && ['write_file','run_command'].includes(tool) || mode==='auto-edit' && tool==='write_file';
|
|
@@ -23,33 +26,102 @@ export function editPreview(oldText,newText,maxLines=60) {
|
|
|
23
26
|
const lines=[`@@ line ${prefix+1} @@`,...changed];
|
|
24
27
|
return lines.length>maxLines?lines.slice(0,maxLines).join('\n')+`\n… ${lines.length-maxLines} more lines (/full shows all)`:lines.join('\n');
|
|
25
28
|
}
|
|
29
|
+
// Friendly, Claude Code–style names for tool calls.
|
|
30
|
+
export function toolTitle(call) {
|
|
31
|
+
const input=call?.input||{};
|
|
32
|
+
const short=s=>{s=String(s??'').replace(/\s+/g,' ');return s.length>70?s.slice(0,69)+'…':s;};
|
|
33
|
+
switch(call?.name) {
|
|
34
|
+
case 'read_file': return `Read(${short(input.path)})`;
|
|
35
|
+
case 'write_file': return `Write(${short(input.path)})`;
|
|
36
|
+
case 'list_files': return `List(${short(input.path||'.')})`;
|
|
37
|
+
case 'run_command': return `Bash(${short(input.command)})`;
|
|
38
|
+
default: return `${call?.name}(${short(input.path||input.command||'')})`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const ANSI=/\x1b\[[0-9;]*m/g;
|
|
42
|
+
const visible=s=>s.replace(ANSI,'').length;
|
|
43
|
+
const tildify=p=>{const home=os.homedir();return p===home||p.startsWith(home+'/')?'~'+p.slice(home.length):p;};
|
|
44
|
+
const SPINNER=['·','✢','✳','✶','✻','✽','✻','✶','✳','✢'];
|
|
26
45
|
export function createUI(clean,output=process.stdout) {
|
|
27
46
|
const color=output.isTTY && !process.env.NO_COLOR && process.env.TERM!=='dumb';
|
|
28
47
|
const paint=(code,text)=>color?`\x1b[${code}m${clean(text)}\x1b[0m`:clean(text);
|
|
29
48
|
const line=text=>output.write(text+'\n');
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
const width=()=>Math.max(40,Math.min(100,(output.columns||80)-2));
|
|
50
|
+
const accent='38;5;209';
|
|
51
|
+
const modeText=mode=>mode==='auto-all'?paint('33','⏵⏵ auto-all (edits + commands)'):mode==='auto-edit'?paint('35','⏵⏵ auto-edit (edits)'):paint('2','ask before edits & commands');
|
|
52
|
+
const fit=(text,max)=>text.length>max?'…'+text.slice(text.length-max+1):text;
|
|
53
|
+
const box=(rows,code='2')=>{
|
|
54
|
+
const w=width(),inner=w-4;
|
|
55
|
+
line(paint(code,'╭'+'─'.repeat(w-2)+'╮'));
|
|
56
|
+
for(const row of rows) line(paint(code,'│')+' '+row+' '.repeat(Math.max(0,inner-visible(row)))+' '+paint(code,'│'));
|
|
57
|
+
line(paint(code,'╰'+'─'.repeat(w-2)+'╯'));
|
|
58
|
+
};
|
|
59
|
+
let spin=null;
|
|
60
|
+
const ui={
|
|
61
|
+
label:text=>line(paint('1',text)),
|
|
32
62
|
note:text=>line(paint('2',text)),
|
|
33
63
|
success:text=>line(paint('32',text)),
|
|
34
64
|
error:text=>line(paint('31',text)),
|
|
35
|
-
prompt:()
|
|
65
|
+
prompt:()=>paint('1','> '),
|
|
66
|
+
modeLine:({model,root,mode,thinking})=>' '+modeText(mode)+paint('2',' (shift+tab to cycle) · '+[model,tildify(root),`thinking ${thinking?'on':'off'}`,'/ for commands'].join(' · ')),
|
|
36
67
|
inputFrame:({model,root,mode,thinking})=>{
|
|
37
|
-
const width=Math.max(54,Math.min(100,(output.columns||84)-4));
|
|
38
|
-
const bg=text=>color?`\x1b[48;5;236m${text}\x1b[0m`:text;
|
|
39
68
|
line('');
|
|
40
|
-
line(
|
|
41
|
-
line(
|
|
42
|
-
line(bg(' '.repeat(width)));
|
|
43
|
-
line(paint('2',` ${model} · ${root} · ${mode} · thinking ${thinking?'on':'off'} · / for commands`));
|
|
69
|
+
line(paint('2','─'.repeat(width())));
|
|
70
|
+
line(ui.modeLine({model,root,mode,thinking}));
|
|
44
71
|
},
|
|
45
|
-
status:({model,root,mode,thinking})=>paint('2',`${model} · ${root} · ${mode} · thinking ${thinking?'on':'off'} · / for commands`),
|
|
72
|
+
status:({model,root,mode,thinking})=>paint('2',`${model} · ${tildify(root)} · ${mode} · thinking ${thinking?'on':'off'} · / for commands`),
|
|
46
73
|
header:({model,root,mode,thinking})=>{
|
|
47
|
-
|
|
48
|
-
line(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
74
|
+
const inner=width()-4;
|
|
75
|
+
line('');
|
|
76
|
+
box([
|
|
77
|
+
paint(accent,'✻')+' '+paint('1','Welcome to kodenique-agent')+paint('2',' '+VERSION),
|
|
78
|
+
'',
|
|
79
|
+
paint('2',' /help for help · /status for your current setup'),
|
|
80
|
+
'',
|
|
81
|
+
paint('2',' cwd: ')+clean(fit(tildify(root),inner-7)),
|
|
82
|
+
paint('2',' model: ')+clean(fit(model,inner-9)),
|
|
83
|
+
paint('2',' approval: ')+paint(mode==='auto-all'?'33':mode==='auto-edit'?'35':'36',`approval: ${mode}`.slice(10))+paint('2',` · thinking: ${thinking?'on':'off'}`),
|
|
84
|
+
],accent);
|
|
85
|
+
// Keep plain "approval: <mode>" text discoverable in non-color output.
|
|
86
|
+
if(!color) line(`approval: ${mode}`);
|
|
87
|
+
line('');
|
|
88
|
+
line(paint('2',' Tips for getting started:'));
|
|
89
|
+
line(paint('2',' 1. Ask questions, edit files, or run commands in this project'));
|
|
90
|
+
line(paint('2',' 2. Be specific for the best results'));
|
|
91
|
+
line(paint('2',' 3. Type / for commands, Tab to complete, /project to switch folders'));
|
|
92
|
+
},
|
|
93
|
+
commandMenu:commands=>{
|
|
94
|
+
for(const [command,description] of commands) line(' '+paint('36',command.padEnd(16))+paint('2',description));
|
|
95
|
+
},
|
|
96
|
+
// Assistant / thinking block headers
|
|
97
|
+
assistant:()=>output.write(paint('1','⏺ ')),
|
|
98
|
+
thinking:()=>output.write(paint('2;3','✻ Thinking… ')),
|
|
99
|
+
tool:call=>line(paint('32','⏺ ')+paint('1',clean(toolTitle(call)))),
|
|
100
|
+
result:(text,ok=true)=>line(paint('2',' ⎿ ')+paint(ok?'2':'31',text)),
|
|
101
|
+
done:text=>line(paint('2',' ⎿ '+text)),
|
|
102
|
+
diff:text=>{for(const row of text.split('\n')) line(' '+paint(row.startsWith('+ ')?'32':row.startsWith('- ')?'31':'2',row));},
|
|
103
|
+
approvalMenu:({question,optionTwo})=>{
|
|
104
|
+
line('');
|
|
105
|
+
line(' '+paint('1',question));
|
|
106
|
+
line(' '+paint(accent,'❯ 1. Yes'));
|
|
107
|
+
line(' 2. '+optionTwo);
|
|
108
|
+
line(' 3. No');
|
|
109
|
+
line(paint('2',' 1/2/3 then Enter · /full shows the complete change · Enter = No'));
|
|
110
|
+
},
|
|
111
|
+
startSpinner:()=>{
|
|
112
|
+
ui.stopSpinner();
|
|
113
|
+
const began=Date.now();let i=0;
|
|
114
|
+
const draw=()=>output.write(`\r\x1b[2K${paint(accent,SPINNER[i++%SPINNER.length])} ${paint(accent,'Working…')} ${paint('2',`(${Math.floor((Date.now()-began)/1000)}s · esc to interrupt)`)}`);
|
|
115
|
+
if(!color) {output.write('Working…');spin={timer:null,plain:true};return;}
|
|
116
|
+
draw();spin={timer:setInterval(draw,120)};
|
|
117
|
+
},
|
|
118
|
+
stopSpinner:()=>{
|
|
119
|
+
if(!spin) return;
|
|
120
|
+
if(spin.timer) clearInterval(spin.timer);
|
|
121
|
+
output.write(spin.plain?'\n':'\r\x1b[2K');spin=null;
|
|
52
122
|
},
|
|
53
|
-
|
|
123
|
+
interrupted:()=>line(paint('2',' ⎿ ')+paint('31','Interrupted by user')),
|
|
124
|
+
paint,
|
|
54
125
|
};
|
|
126
|
+
return ui;
|
|
55
127
|
}
|