@bahulam/code 2.6.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 +80 -0
- package/package.json +49 -0
- package/pulse/app/activity/page.tsx +190 -0
- package/pulse/app/api/activity/route.ts +138 -0
- package/pulse/app/api/benchmark/route.ts +113 -0
- package/pulse/app/api/benchmarks/route.ts +195 -0
- package/pulse/app/api/costs/route.ts +88 -0
- package/pulse/app/api/export/route.ts +77 -0
- package/pulse/app/api/history/route.ts +11 -0
- package/pulse/app/api/import/route.ts +31 -0
- package/pulse/app/api/memory/route.ts +50 -0
- package/pulse/app/api/plans/route.ts +9 -0
- package/pulse/app/api/projects/[slug]/route.ts +96 -0
- package/pulse/app/api/projects/route.ts +121 -0
- package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
- package/pulse/app/api/sessions/[id]/route.ts +31 -0
- package/pulse/app/api/sessions/route.ts +112 -0
- package/pulse/app/api/settings/route.ts +14 -0
- package/pulse/app/api/stats/route.ts +143 -0
- package/pulse/app/api/todos/route.ts +9 -0
- package/pulse/app/api/tools/route.ts +160 -0
- package/pulse/app/benchmarks/page.tsx +224 -0
- package/pulse/app/costs/page.tsx +179 -0
- package/pulse/app/export/page.tsx +465 -0
- package/pulse/app/favicon.ico +0 -0
- package/pulse/app/globals.css +263 -0
- package/pulse/app/help/page.tsx +143 -0
- package/pulse/app/history/page.tsx +157 -0
- package/pulse/app/layout.tsx +46 -0
- package/pulse/app/memory/page.tsx +365 -0
- package/pulse/app/overview-client.tsx +393 -0
- package/pulse/app/page.tsx +14 -0
- package/pulse/app/plans/page.tsx +308 -0
- package/pulse/app/projects/[slug]/page.tsx +390 -0
- package/pulse/app/projects/page.tsx +110 -0
- package/pulse/app/sessions/[id]/page.tsx +243 -0
- package/pulse/app/sessions/page.tsx +39 -0
- package/pulse/app/settings/page.tsx +188 -0
- package/pulse/app/todos/page.tsx +211 -0
- package/pulse/app/tools/page.tsx +249 -0
- package/pulse/cli.js +164 -0
- package/pulse/components/activity/day-of-week-chart.tsx +35 -0
- package/pulse/components/activity/streak-card.tsx +36 -0
- package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
- package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
- package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
- package/pulse/components/costs/model-token-table.tsx +60 -0
- package/pulse/components/global-search.tsx +193 -0
- package/pulse/components/keyboard-nav-provider.tsx +23 -0
- package/pulse/components/layout/bottom-nav.tsx +53 -0
- package/pulse/components/layout/client-layout.tsx +31 -0
- package/pulse/components/layout/sidebar-context.tsx +50 -0
- package/pulse/components/layout/sidebar.tsx +183 -0
- package/pulse/components/layout/top-bar.tsx +121 -0
- package/pulse/components/overview/activity-heatmap.tsx +107 -0
- package/pulse/components/overview/conversation-table.tsx +148 -0
- package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
- package/pulse/components/overview/peak-hours-chart.tsx +87 -0
- package/pulse/components/overview/project-activity-donut.tsx +96 -0
- package/pulse/components/overview/stat-card.tsx +102 -0
- package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
- package/pulse/components/projects/project-card.tsx +175 -0
- package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
- package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
- package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
- package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
- package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
- package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
- package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
- package/pulse/components/sessions/session-badges.tsx +49 -0
- package/pulse/components/sessions/session-table.tsx +299 -0
- package/pulse/components/theme-provider.tsx +44 -0
- package/pulse/components/tools/feature-adoption-table.tsx +58 -0
- package/pulse/components/tools/mcp-server-panel.tsx +45 -0
- package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
- package/pulse/components/tools/version-history-table.tsx +32 -0
- package/pulse/components/ui/alert.tsx +66 -0
- package/pulse/components/ui/badge.tsx +48 -0
- package/pulse/components/ui/breadcrumb.tsx +109 -0
- package/pulse/components/ui/button.tsx +64 -0
- package/pulse/components/ui/calendar.tsx +220 -0
- package/pulse/components/ui/card.tsx +92 -0
- package/pulse/components/ui/command.tsx +158 -0
- package/pulse/components/ui/dialog.tsx +158 -0
- package/pulse/components/ui/input.tsx +21 -0
- package/pulse/components/ui/popover.tsx +89 -0
- package/pulse/components/ui/progress.tsx +31 -0
- package/pulse/components/ui/select.tsx +190 -0
- package/pulse/components/ui/separator.tsx +28 -0
- package/pulse/components/ui/sheet.tsx +143 -0
- package/pulse/components/ui/skeleton.tsx +13 -0
- package/pulse/components/ui/table.tsx +116 -0
- package/pulse/components/ui/tabs.tsx +91 -0
- package/pulse/components/ui/tooltip.tsx +57 -0
- package/pulse/components/use-global-keyboard-nav.ts +79 -0
- package/pulse/components.json +23 -0
- package/pulse/eslint.config.mjs +18 -0
- package/pulse/lib/bahulam-paths.ts +23 -0
- package/pulse/lib/claude-reader.ts +592 -0
- package/pulse/lib/decode.ts +129 -0
- package/pulse/lib/pricing.ts +102 -0
- package/pulse/lib/replay-parser.ts +165 -0
- package/pulse/lib/tool-categories.ts +127 -0
- package/pulse/lib/utils.ts +6 -0
- package/pulse/next-env.d.ts +6 -0
- package/pulse/next.config.ts +16 -0
- package/pulse/package.json +45 -0
- package/pulse/postcss.config.mjs +7 -0
- package/pulse/public/activity.png +0 -0
- package/pulse/public/cc-lens.png +0 -0
- package/pulse/public/command-k.png +0 -0
- package/pulse/public/costs.png +0 -0
- package/pulse/public/dashboard-dark.png +0 -0
- package/pulse/public/dashboard-white.png +0 -0
- package/pulse/public/export.png +0 -0
- package/pulse/public/file.svg +1 -0
- package/pulse/public/globe.svg +1 -0
- package/pulse/public/next.svg +1 -0
- package/pulse/public/projects.png +0 -0
- package/pulse/public/session-chat.png +0 -0
- package/pulse/public/todos.png +0 -0
- package/pulse/public/tools.png +0 -0
- package/pulse/public/vercel.svg +1 -0
- package/pulse/public/window.svg +1 -0
- package/pulse/tsconfig.json +34 -0
- package/pulse/types/claude.ts +294 -0
- package/src/agents/loader.mjs +94 -0
- package/src/agents/multi_workflow_loader.mjs +330 -0
- package/src/agents/parser.mjs +205 -0
- package/src/agents/scaffold.mjs +222 -0
- package/src/agents/teams.mjs +123 -0
- package/src/agents/workflow_loader.mjs +122 -0
- package/src/agents/workflow_scaffold.mjs +249 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/auth/tarang-auth.mjs +306 -0
- package/src/commands/agent.mjs +220 -0
- package/src/commands/workflow.mjs +581 -0
- package/src/config/cli-args.mjs +200 -0
- package/src/config/env.mjs +263 -0
- package/src/config/hook-runner.mjs +100 -0
- package/src/config/memory-loader.mjs +32 -0
- package/src/config/settings-loader.mjs +45 -0
- package/src/config/settings.mjs +132 -0
- package/src/context/ast-parser.mjs +298 -0
- package/src/context/bm25.mjs +85 -0
- package/src/context/retriever.mjs +308 -0
- package/src/context/skeleton.mjs +134 -0
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/agent-history.mjs +111 -0
- package/src/core/agent-loop.mjs +486 -0
- package/src/core/approval-log.mjs +104 -0
- package/src/core/approval.mjs +476 -0
- package/src/core/attachments.mjs +380 -0
- package/src/core/backend-url.mjs +55 -0
- package/src/core/cache-control.mjs +92 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/callback-client.mjs +180 -0
- package/src/core/checkpoints.mjs +142 -0
- package/src/core/compact-history.mjs +127 -0
- package/src/core/context-envelope.mjs +54 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/error-guidance.mjs +311 -0
- package/src/core/file-diff.mjs +217 -0
- package/src/core/headless.mjs +448 -0
- package/src/core/hooks-manager.mjs +87 -0
- package/src/core/jsonl-writer.mjs +449 -0
- package/src/core/local-agent.mjs +537 -0
- package/src/core/local-store.mjs +836 -0
- package/src/core/mode-selector.mjs +51 -0
- package/src/core/output-filter.mjs +177 -0
- package/src/core/paths.mjs +190 -0
- package/src/core/policy-resolver.mjs +156 -0
- package/src/core/pricing.mjs +336 -0
- package/src/core/project-artifacts.mjs +39 -0
- package/src/core/project-context-loader.mjs +139 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limit-display.mjs +121 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/resume-mode.mjs +192 -0
- package/src/core/risk-tier.mjs +337 -0
- package/src/core/safety.mjs +203 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session-manager.mjs +360 -0
- package/src/core/session.mjs +143 -0
- package/src/core/settings-sync.mjs +85 -0
- package/src/core/stagnation.mjs +57 -0
- package/src/core/stream-client.mjs +829 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +140 -0
- package/src/core/tasks.mjs +196 -0
- package/src/core/tool-executor.mjs +1950 -0
- package/src/core/trust.mjs +158 -0
- package/src/core/work-scope.mjs +248 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/index.mjs +426 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/onboarding/preflight.mjs +360 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/command-classifier.mjs +652 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/installer.mjs +188 -0
- package/src/skills/loader.mjs +252 -0
- package/src/skills/runner.mjs +55 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/telemetry/index.mjs +96 -0
- package/src/terminal/agents.mjs +177 -0
- package/src/terminal/analytics.mjs +292 -0
- package/src/terminal/ansi.mjs +695 -0
- package/src/terminal/init.mjs +145 -0
- package/src/terminal/main.mjs +269 -0
- package/src/terminal/repl-explore.mjs +35 -0
- package/src/terminal/repl-format.mjs +257 -0
- package/src/terminal/repl-render.mjs +561 -0
- package/src/terminal/repl-resume.mjs +625 -0
- package/src/terminal/repl-state.mjs +103 -0
- package/src/terminal/repl-utils.mjs +34 -0
- package/src/terminal/repl.mjs +3832 -0
- package/src/terminal/skills.mjs +54 -0
- package/src/terminal/tool-display.mjs +240 -0
- package/src/tools/agent.mjs +137 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +231 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +129 -0
- package/src/tools/lint.mjs +71 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/project-overview.mjs +641 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +116 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/test-runner.mjs +49 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/approval.mjs +263 -0
- package/src/ui/banner.mjs +235 -0
- package/src/ui/commands.mjs +537 -0
- package/src/ui/formatter.mjs +409 -0
- package/src/ui/icons.mjs +164 -0
- package/src/ui/input-dock.mjs +444 -0
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/mission-report.mjs +296 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/slash-commands.mjs +245 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/sub-agent.mjs +152 -0
- package/src/ui/term.mjs +159 -0
- package/src/ui/text-layout.mjs +127 -0
- package/src/ui/tool-card.mjs +463 -0
- package/src/ui/tool-details.mjs +312 -0
- package/src/ui/transcript-block.mjs +21 -0
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TarangStreamClient — SSE consumer for Tarang backend.
|
|
3
|
+
*
|
|
4
|
+
* Replaces OCC's agent-loop.mjs. Instead of calling the LLM API directly,
|
|
5
|
+
* this client POSTs to the Tarang backend, parses the SSE stream, intercepts
|
|
6
|
+
* tool_request/tool_call events (executes locally, POSTs callback), and
|
|
7
|
+
* yields all other events to the caller for rendering.
|
|
8
|
+
*
|
|
9
|
+
* Phase 2: handles all 22 event types. Approval flow integrated.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { llmToolResultContent, sendCallback, sendSkippedCallback, sendApprovalDecision } from './callback-client.mjs';
|
|
13
|
+
import { ApprovalManager } from './approval.mjs';
|
|
14
|
+
import { normalizeBillingBrandCopy, quotaErrorDetail, rateLimitErrorMessage } from './rate-limit-display.mjs';
|
|
15
|
+
import * as telemetry from '../telemetry/index.mjs';
|
|
16
|
+
|
|
17
|
+
export const EVENT_TYPES = Object.freeze({
|
|
18
|
+
// Phase 1 — handled
|
|
19
|
+
STATUS: 'status',
|
|
20
|
+
TOOL_REQUEST: 'tool_request',
|
|
21
|
+
TOOL_CALL: 'tool_call',
|
|
22
|
+
PLAN: 'plan',
|
|
23
|
+
ERROR: 'error',
|
|
24
|
+
COMPLETE: 'complete',
|
|
25
|
+
// Phase 2 — stubbed
|
|
26
|
+
SESSION_INFO: 'session_info',
|
|
27
|
+
TOOL_DONE: 'tool_done',
|
|
28
|
+
THINKING: 'thinking',
|
|
29
|
+
PHASE_UPDATE: 'phase_update',
|
|
30
|
+
PHASE_SUMMARY: 'phase_summary',
|
|
31
|
+
PHASE_START: 'phase_start',
|
|
32
|
+
WORKER_UPDATE: 'worker_update',
|
|
33
|
+
WORKER_START: 'worker_start',
|
|
34
|
+
WORKER_DONE: 'worker_done',
|
|
35
|
+
DELEGATION: 'delegation',
|
|
36
|
+
CHANGE: 'change',
|
|
37
|
+
CONTENT: 'content',
|
|
38
|
+
CONTENT_PARTIAL: 'content_partial',
|
|
39
|
+
TOOL_RESULT: 'tool_result',
|
|
40
|
+
FILE_DIFF: 'file_diff',
|
|
41
|
+
SUB_AGENT_START: 'sub_agent_start',
|
|
42
|
+
SUB_AGENT_TOOL: 'sub_agent_tool',
|
|
43
|
+
SUB_AGENT_COMPLETE: 'sub_agent_complete',
|
|
44
|
+
STAGNATION: 'stagnation',
|
|
45
|
+
CANCELLED: 'cancelled',
|
|
46
|
+
PAUSED: 'paused',
|
|
47
|
+
RESUMED: 'resumed',
|
|
48
|
+
PAUSE_INSTRUCTION: 'pause_instruction',
|
|
49
|
+
RECONNECTING: 'reconnecting',
|
|
50
|
+
RECONNECTED: 'reconnected',
|
|
51
|
+
RECONNECT_FAILED: 'reconnect_failed',
|
|
52
|
+
// HITL approval events (from framework)
|
|
53
|
+
APPROVAL_REQUIRED: 'approval_required',
|
|
54
|
+
APPROVAL_GRANTED: 'approval_granted',
|
|
55
|
+
APPROVAL_DENIED: 'approval_denied',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function sleep(ms) {
|
|
59
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Full jitter around the scheduled delay: pick a value in [delay*0.5, delay*1.5].
|
|
63
|
+
// Spreads out reconnect storms so N clients dropping simultaneously don't
|
|
64
|
+
// synchronize their retries. Clamped to the same 30s ceiling as the base delay.
|
|
65
|
+
function jitteredDelay(delayMs) {
|
|
66
|
+
const min = Math.floor(delayMs * 0.5);
|
|
67
|
+
const max = Math.floor(delayMs * 1.5);
|
|
68
|
+
const jittered = min + Math.floor(Math.random() * (max - min + 1));
|
|
69
|
+
return Math.max(200, Math.min(30_000, jittered));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Categorize a fetch error. "offline-class" means DNS/routing failures that
|
|
73
|
+
// almost never recover within a few seconds — we bail after 2 in a row rather
|
|
74
|
+
// than burn the full 5-min budget when the user's network is truly down.
|
|
75
|
+
function isOfflineLikelyError(err) {
|
|
76
|
+
const code = err?.cause?.code || err?.code || '';
|
|
77
|
+
return (
|
|
78
|
+
code === 'ENOTFOUND' || // DNS lookup failed
|
|
79
|
+
code === 'EAI_AGAIN' || // DNS temporary failure
|
|
80
|
+
code === 'ENETUNREACH' || // no route to host
|
|
81
|
+
code === 'EHOSTUNREACH' || // host unreachable
|
|
82
|
+
code === 'UND_ERR_CONNECT_TIMEOUT' // undici connect timeout
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class TarangStreamClient {
|
|
87
|
+
/**
|
|
88
|
+
* @param {Object} opts
|
|
89
|
+
* @param {string} opts.baseUrl - Tarang backend URL
|
|
90
|
+
* @param {string} opts.token - CLI auth token
|
|
91
|
+
* @param {Object} opts.toolExecutor - { execute(name, args) }
|
|
92
|
+
* @param {boolean} [opts.verbose=false]
|
|
93
|
+
*/
|
|
94
|
+
constructor({
|
|
95
|
+
baseUrl,
|
|
96
|
+
token,
|
|
97
|
+
toolExecutor,
|
|
98
|
+
verbose = false,
|
|
99
|
+
approvalManager = null,
|
|
100
|
+
product = null,
|
|
101
|
+
reconnectMaxElapsedMs = null,
|
|
102
|
+
}) {
|
|
103
|
+
this.baseUrl = (baseUrl || '').replace(/\/$/, '');
|
|
104
|
+
this.token = token;
|
|
105
|
+
this.toolExecutor = toolExecutor;
|
|
106
|
+
this.verbose = verbose;
|
|
107
|
+
this.approval = approvalManager || new ApprovalManager();
|
|
108
|
+
this.product = product || process.env.TARANG_PRODUCT || process.env.KEPLER_PRODUCT || 'kepler';
|
|
109
|
+
this.currentTaskId = null;
|
|
110
|
+
this.lastEventId = null;
|
|
111
|
+
this.retryDelayMs = null;
|
|
112
|
+
this.pendingToolCallbacks = new Map();
|
|
113
|
+
this.reconnectMaxElapsedMs = reconnectMaxElapsedMs
|
|
114
|
+
?? Number(process.env.KEPLER_RECONNECT_MAX_ELAPSED_MS || 300_000);
|
|
115
|
+
// Set by backend on first turn, reused on subsequent turns. Headless mode
|
|
116
|
+
// (which starts fresh per invocation) can pre-seed via TARANG_SESSION_ID
|
|
117
|
+
// so multi-turn benchmarks share one backend session across `node` runs.
|
|
118
|
+
this.sessionId = process.env.TARANG_SESSION_ID || null;
|
|
119
|
+
this._cancelled = false;
|
|
120
|
+
this._paused = false;
|
|
121
|
+
this._pauseWaiters = new Set();
|
|
122
|
+
this._abort = null;
|
|
123
|
+
this._toolAbort = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_headers(extra = {}) {
|
|
127
|
+
const headers = { ...extra };
|
|
128
|
+
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
129
|
+
if (this.product) headers['X-Product'] = this.product;
|
|
130
|
+
return headers;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async analyzeVision({ instruction, attachments }) {
|
|
134
|
+
const url = `${this.baseUrl}/api/vision/analyze`;
|
|
135
|
+
const body = { instruction, attachments: attachments || [] };
|
|
136
|
+
const response = await fetch(url, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: this._headers({ 'Content-Type': 'application/json' }),
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
});
|
|
141
|
+
const text = await response.text().catch(() => '');
|
|
142
|
+
let payload = null;
|
|
143
|
+
try {
|
|
144
|
+
payload = text ? JSON.parse(text) : null;
|
|
145
|
+
} catch {
|
|
146
|
+
payload = { detail: text };
|
|
147
|
+
}
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const detail = payload?.detail || payload || {};
|
|
150
|
+
const message = typeof detail === 'string'
|
|
151
|
+
? detail
|
|
152
|
+
: detail.message || detail.error || `Vision analysis failed (${response.status})`;
|
|
153
|
+
const err = new Error(message);
|
|
154
|
+
err.status = response.status;
|
|
155
|
+
err.detail = detail;
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
return payload || {};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Execute an instruction via SSE stream.
|
|
163
|
+
* Yields parsed events. Client-side tool requests are shown, executed
|
|
164
|
+
* locally, callback-posted, then followed by a local tool_result event.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} instruction
|
|
167
|
+
* @param {Object} [context={}]
|
|
168
|
+
* @param {string} [model]
|
|
169
|
+
* @yields {{ type: string, data: Object }}
|
|
170
|
+
*/
|
|
171
|
+
async *execute(instruction, context = {}, messages = null) {
|
|
172
|
+
this._cancelled = false;
|
|
173
|
+
this.currentTaskId = null;
|
|
174
|
+
|
|
175
|
+
const url = `${this.baseUrl}/api/execute`;
|
|
176
|
+
const body = { instruction, context };
|
|
177
|
+
if (messages && messages.length > 0) body.messages = messages;
|
|
178
|
+
if (this.sessionId) body.session_id = this.sessionId;
|
|
179
|
+
|
|
180
|
+
const headers = this._headers({
|
|
181
|
+
'Accept': 'text/event-stream',
|
|
182
|
+
'Content-Type': 'application/json',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Abort controller so cancel() can break out of a stalled reader
|
|
186
|
+
// instead of waiting for the next SSE event to notice _cancelled.
|
|
187
|
+
this._abort = new AbortController();
|
|
188
|
+
this._toolAbort = new AbortController();
|
|
189
|
+
|
|
190
|
+
let response;
|
|
191
|
+
try {
|
|
192
|
+
response = await fetch(url, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers,
|
|
195
|
+
body: JSON.stringify(body),
|
|
196
|
+
signal: this._abort.signal,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err.name === 'AbortError') {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, fatal: true } };
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (response.status === 401) {
|
|
207
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `bahulam-code login` to re-authenticate.', fatal: true } };
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (response.status === 429) {
|
|
211
|
+
const text = await response.text().catch(() => '');
|
|
212
|
+
let payload = null;
|
|
213
|
+
try {
|
|
214
|
+
payload = text ? JSON.parse(text) : null;
|
|
215
|
+
} catch {
|
|
216
|
+
payload = { detail: { message: text } };
|
|
217
|
+
}
|
|
218
|
+
const detail = quotaErrorDetail(payload);
|
|
219
|
+
yield {
|
|
220
|
+
type: EVENT_TYPES.ERROR,
|
|
221
|
+
data: {
|
|
222
|
+
message: rateLimitErrorMessage(payload),
|
|
223
|
+
code: detail?.code || 'rate_limited',
|
|
224
|
+
retry_after: detail?.retry_after ?? detail?.rate_limit?.retry_after,
|
|
225
|
+
rate_limit: detail?.rate_limit || null,
|
|
226
|
+
action: detail?.action || null,
|
|
227
|
+
pricing_url: normalizeBillingBrandCopy(detail?.pricing_url || null),
|
|
228
|
+
fatal: true,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const text = await response.text().catch(() => 'Unknown error');
|
|
235
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, fatal: true } };
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
yield* this._consumeResponse(response);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (this._cancelled) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
yield* this._reconnectAfterDrop(err);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async *_consumeResponse(response) {
|
|
250
|
+
const taskId = response.headers.get('X-Task-ID');
|
|
251
|
+
if (taskId) this.currentTaskId = taskId;
|
|
252
|
+
|
|
253
|
+
for await (const parsed of this._parseSSE(response)) {
|
|
254
|
+
if (this._cancelled) return;
|
|
255
|
+
await this._waitIfPaused();
|
|
256
|
+
if (this._cancelled) return;
|
|
257
|
+
|
|
258
|
+
if (parsed.id != null) this.lastEventId = parsed.id;
|
|
259
|
+
if (parsed.retry != null) this.retryDelayMs = parsed.retry;
|
|
260
|
+
if (parsed.data?.task_id) this.currentTaskId = parsed.data.task_id;
|
|
261
|
+
|
|
262
|
+
for await (const event of this._handleStreamEvent(parsed)) {
|
|
263
|
+
yield event;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async *consumeEventStream(response) {
|
|
269
|
+
yield* this._consumeResponse(response);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async *_handleStreamEvent({ event, data, id = null, retry = null }) {
|
|
273
|
+
const rendered = { type: event, data, event_id: id, retry };
|
|
274
|
+
|
|
275
|
+
// Capture session_id from backend (first turn creates it, subsequent turns reuse)
|
|
276
|
+
if (event === EVENT_TYPES.SESSION_INFO && data?.session_id) {
|
|
277
|
+
this.sessionId = data.session_id;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Framework HITL: approval_required — show menu, POST decision
|
|
281
|
+
if (event === EVENT_TYPES.APPROVAL_REQUIRED) {
|
|
282
|
+
yield rendered;
|
|
283
|
+
const approvalEvent = await this._handleApprovalRequired(data);
|
|
284
|
+
if (approvalEvent) yield approvalEvent;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (event === EVENT_TYPES.APPROVAL_GRANTED || event === EVENT_TYPES.APPROVAL_DENIED) {
|
|
289
|
+
yield rendered;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Tool requests — show to user, then execute locally and POST callback.
|
|
294
|
+
if (event === EVENT_TYPES.TOOL_REQUEST || event === EVENT_TYPES.TOOL_CALL) {
|
|
295
|
+
yield rendered;
|
|
296
|
+
await this._waitIfPaused();
|
|
297
|
+
if (this._cancelled || data?.server_side) return;
|
|
298
|
+
const toolEvent = await this._handleToolRequest(data);
|
|
299
|
+
if (toolEvent) {
|
|
300
|
+
await this._waitIfPaused();
|
|
301
|
+
if (this._cancelled) return;
|
|
302
|
+
yield toolEvent;
|
|
303
|
+
for (const diffEvent of fileDiffEventsForToolResult(toolEvent)) {
|
|
304
|
+
yield diffEvent;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
yield rendered;
|
|
311
|
+
if (event === EVENT_TYPES.TOOL_RESULT || event === EVENT_TYPES.TOOL_DONE) {
|
|
312
|
+
for (const diffEvent of fileDiffEventsForToolResult(rendered)) {
|
|
313
|
+
yield diffEvent;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async *_reconnectAfterDrop(err) {
|
|
319
|
+
const taskId = this.currentTaskId;
|
|
320
|
+
if (!taskId || this.lastEventId == null) {
|
|
321
|
+
yield {
|
|
322
|
+
type: EVENT_TYPES.ERROR,
|
|
323
|
+
data: {
|
|
324
|
+
message: `Stream interrupted: ${err?.message || 'connection lost'}. Use /resume to continue from saved history.`,
|
|
325
|
+
fatal: true,
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const started = Date.now();
|
|
332
|
+
let attempt = 0;
|
|
333
|
+
// Slow-start baseline: 500ms for the first retry catches most
|
|
334
|
+
// transient blips before the user notices; the ramp doubles from
|
|
335
|
+
// there. Server-hinted `retry:` from the last SSE frame overrides
|
|
336
|
+
// if present. Capped at 30s.
|
|
337
|
+
let delayMs = Math.max(200, Math.min(this.retryDelayMs || 500, 30_000));
|
|
338
|
+
let offlineStreak = 0;
|
|
339
|
+
|
|
340
|
+
while (!this._cancelled && Date.now() - started < this.reconnectMaxElapsedMs) {
|
|
341
|
+
attempt++;
|
|
342
|
+
const after = this.lastEventId;
|
|
343
|
+
// Apply jitter to the scheduled delay so N clients dropping at
|
|
344
|
+
// the same time don't lockstep-retry against the server. The
|
|
345
|
+
// event surfaces the ACTUAL wait so the CLI can show it.
|
|
346
|
+
const waitMs = jitteredDelay(delayMs);
|
|
347
|
+
telemetry.track('stream.reconnect.attempt', {
|
|
348
|
+
task_id: taskId,
|
|
349
|
+
after,
|
|
350
|
+
attempt,
|
|
351
|
+
base_delay_ms: delayMs,
|
|
352
|
+
delay_ms: waitMs,
|
|
353
|
+
});
|
|
354
|
+
yield {
|
|
355
|
+
type: EVENT_TYPES.RECONNECTING,
|
|
356
|
+
data: { task_id: taskId, after, attempt, delay_ms: waitMs },
|
|
357
|
+
};
|
|
358
|
+
await sleep(waitMs);
|
|
359
|
+
try {
|
|
360
|
+
const url = `${this.baseUrl}/api/execute/${encodeURIComponent(taskId)}/events?after=${encodeURIComponent(after)}`;
|
|
361
|
+
const response = await fetch(url, {
|
|
362
|
+
method: 'GET',
|
|
363
|
+
headers: this._headers({ Accept: 'text/event-stream' }),
|
|
364
|
+
signal: AbortSignal.timeout(30_000),
|
|
365
|
+
});
|
|
366
|
+
if (response.status === 404 || response.status === 410) {
|
|
367
|
+
telemetry.track('stream.reconnect.failed', {
|
|
368
|
+
task_id: taskId,
|
|
369
|
+
after,
|
|
370
|
+
attempt,
|
|
371
|
+
status: response.status,
|
|
372
|
+
code: response.status === 410 ? 'reconnect_buffer_expired' : 'task_not_found',
|
|
373
|
+
retryable: false,
|
|
374
|
+
});
|
|
375
|
+
yield {
|
|
376
|
+
type: EVENT_TYPES.RECONNECT_FAILED,
|
|
377
|
+
data: {
|
|
378
|
+
task_id: taskId,
|
|
379
|
+
code: response.status === 410 ? 'reconnect_buffer_expired' : 'task_not_found',
|
|
380
|
+
retryable: false,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!response.ok) throw new Error(`reconnect failed (${response.status})`);
|
|
386
|
+
let replayed = 0;
|
|
387
|
+
for await (const event of this._consumeResponse(response)) {
|
|
388
|
+
if (event.type !== EVENT_TYPES.RECONNECTED) replayed++;
|
|
389
|
+
if (event.type === EVENT_TYPES.RECONNECTED) {
|
|
390
|
+
replayed = Number(event.data?.replayed ?? replayed);
|
|
391
|
+
}
|
|
392
|
+
yield event;
|
|
393
|
+
}
|
|
394
|
+
telemetry.track('stream.reconnect.succeeded', {
|
|
395
|
+
task_id: taskId,
|
|
396
|
+
after,
|
|
397
|
+
attempt,
|
|
398
|
+
replayed,
|
|
399
|
+
elapsed_ms: Date.now() - started,
|
|
400
|
+
});
|
|
401
|
+
return;
|
|
402
|
+
} catch (nextErr) {
|
|
403
|
+
if (this._cancelled) return;
|
|
404
|
+
// Offline-class errors (DNS failure, no route) rarely recover
|
|
405
|
+
// in seconds. Bail after 2 in a row so we don't burn the full
|
|
406
|
+
// 5-min budget spinning against a dead network — the user
|
|
407
|
+
// gets an actionable "network unreachable" message instead.
|
|
408
|
+
if (isOfflineLikelyError(nextErr)) {
|
|
409
|
+
offlineStreak++;
|
|
410
|
+
if (offlineStreak >= 2) {
|
|
411
|
+
telemetry.track('stream.reconnect.failed', {
|
|
412
|
+
task_id: taskId,
|
|
413
|
+
after,
|
|
414
|
+
attempt,
|
|
415
|
+
code: 'network_unreachable',
|
|
416
|
+
error_code: nextErr?.cause?.code || nextErr?.code || '',
|
|
417
|
+
retryable: true,
|
|
418
|
+
});
|
|
419
|
+
yield {
|
|
420
|
+
type: EVENT_TYPES.RECONNECT_FAILED,
|
|
421
|
+
data: {
|
|
422
|
+
task_id: taskId,
|
|
423
|
+
after,
|
|
424
|
+
code: 'network_unreachable',
|
|
425
|
+
message: 'Network appears unreachable — DNS lookup failed twice. Check your connection, then /resume to continue.',
|
|
426
|
+
retryable: true,
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
offlineStreak = 0;
|
|
433
|
+
}
|
|
434
|
+
telemetry.track('stream.reconnect.retry', {
|
|
435
|
+
task_id: taskId,
|
|
436
|
+
after,
|
|
437
|
+
attempt,
|
|
438
|
+
message: nextErr?.message || 'reconnect failed',
|
|
439
|
+
error_code: nextErr?.cause?.code || nextErr?.code || '',
|
|
440
|
+
});
|
|
441
|
+
err = nextErr;
|
|
442
|
+
delayMs = Math.min(delayMs * 2, 30_000);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
telemetry.track('stream.reconnect.failed', {
|
|
447
|
+
task_id: taskId,
|
|
448
|
+
after: this.lastEventId,
|
|
449
|
+
code: 'reconnect_timeout',
|
|
450
|
+
retryable: false,
|
|
451
|
+
elapsed_ms: Date.now() - started,
|
|
452
|
+
});
|
|
453
|
+
yield {
|
|
454
|
+
type: EVENT_TYPES.RECONNECT_FAILED,
|
|
455
|
+
data: {
|
|
456
|
+
task_id: taskId,
|
|
457
|
+
after: this.lastEventId,
|
|
458
|
+
code: 'reconnect_timeout',
|
|
459
|
+
message: err?.message || 'connection lost',
|
|
460
|
+
retryable: false,
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Parse SSE from a fetch Response using ReadableStream.
|
|
467
|
+
* @param {Response} response
|
|
468
|
+
* @yields {{ event: string, data: Object, id: string|null, retry: number|null }}
|
|
469
|
+
*/
|
|
470
|
+
async *_parseSSE(response) {
|
|
471
|
+
const reader = response.body.getReader();
|
|
472
|
+
const decoder = new TextDecoder();
|
|
473
|
+
let buffer = '';
|
|
474
|
+
let currentEvent = 'message';
|
|
475
|
+
let currentData = [];
|
|
476
|
+
let currentId = null;
|
|
477
|
+
let currentRetry = null;
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
while (true) {
|
|
481
|
+
let read;
|
|
482
|
+
try {
|
|
483
|
+
read = await reader.read();
|
|
484
|
+
} catch (err) {
|
|
485
|
+
// Aborted via cancel() — treat as a clean end-of-stream.
|
|
486
|
+
if (err && (err.name === 'AbortError' || this._cancelled)) break;
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
489
|
+
const { done, value } = read;
|
|
490
|
+
if (done) break;
|
|
491
|
+
|
|
492
|
+
buffer += decoder.decode(value, { stream: true });
|
|
493
|
+
const lines = buffer.split('\n');
|
|
494
|
+
buffer = lines.pop(); // keep incomplete last line
|
|
495
|
+
|
|
496
|
+
for (const line of lines) {
|
|
497
|
+
const rawLine = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
498
|
+
const trimmed = rawLine.trim();
|
|
499
|
+
|
|
500
|
+
if (!trimmed) {
|
|
501
|
+
// Empty line = event boundary
|
|
502
|
+
if (currentData.length > 0) {
|
|
503
|
+
const rawData = currentData.join('\n');
|
|
504
|
+
let parsed;
|
|
505
|
+
try {
|
|
506
|
+
parsed = JSON.parse(rawData);
|
|
507
|
+
} catch {
|
|
508
|
+
parsed = { message: rawData };
|
|
509
|
+
}
|
|
510
|
+
yield { event: currentEvent, data: parsed, id: currentId, retry: currentRetry };
|
|
511
|
+
}
|
|
512
|
+
currentEvent = 'message';
|
|
513
|
+
currentData = [];
|
|
514
|
+
currentId = null;
|
|
515
|
+
currentRetry = null;
|
|
516
|
+
} else if (trimmed.startsWith('event:')) {
|
|
517
|
+
currentEvent = trimmed.slice(6).trim();
|
|
518
|
+
} else if (trimmed.startsWith('data:')) {
|
|
519
|
+
currentData.push(trimmed.slice(5).trim());
|
|
520
|
+
} else if (trimmed.startsWith('id:')) {
|
|
521
|
+
currentId = trimmed.slice(3).trim();
|
|
522
|
+
} else if (trimmed.startsWith('retry:')) {
|
|
523
|
+
const parsedRetry = Number(trimmed.slice(6).trim());
|
|
524
|
+
if (Number.isFinite(parsedRetry) && parsedRetry >= 0) currentRetry = parsedRetry;
|
|
525
|
+
}
|
|
526
|
+
// comments and unknown SSE fields are ignored.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Flush remaining
|
|
531
|
+
if (currentData.length > 0) {
|
|
532
|
+
const rawData = currentData.join('\n');
|
|
533
|
+
let parsed;
|
|
534
|
+
try {
|
|
535
|
+
parsed = JSON.parse(rawData);
|
|
536
|
+
} catch {
|
|
537
|
+
parsed = { message: rawData };
|
|
538
|
+
}
|
|
539
|
+
yield { event: currentEvent, data: parsed, id: currentId, retry: currentRetry };
|
|
540
|
+
}
|
|
541
|
+
} finally {
|
|
542
|
+
reader.releaseLock();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Handle a tool_call event: execute tool locally, POST result via callback.
|
|
548
|
+
*
|
|
549
|
+
* Approval is handled by the framework (HITL) BEFORE this is called.
|
|
550
|
+
* By the time a tool_call arrives here, it's already approved.
|
|
551
|
+
*
|
|
552
|
+
* @param {Object} data - { call_id, tool, args }
|
|
553
|
+
* @returns {Object} local tool result event to yield
|
|
554
|
+
*/
|
|
555
|
+
async _handleToolRequest(data) {
|
|
556
|
+
const { call_id, request_id, tool, args } = data;
|
|
557
|
+
const callId = call_id || request_id;
|
|
558
|
+
const toolName = tool;
|
|
559
|
+
const isInternal = Boolean(data?.internal || data?.sub_agent);
|
|
560
|
+
|
|
561
|
+
if (this.verbose) {
|
|
562
|
+
process.stderr.write(`\x1b[2m[tool] ${toolName}(${JSON.stringify(args).slice(0, 80)}...)\x1b[0m\n`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (this._cancelled) {
|
|
566
|
+
return {
|
|
567
|
+
type: EVENT_TYPES.TOOL_RESULT,
|
|
568
|
+
data: {
|
|
569
|
+
success: false,
|
|
570
|
+
output: 'Cancelled by user',
|
|
571
|
+
call_id: callId,
|
|
572
|
+
tool: toolName,
|
|
573
|
+
args: args || {},
|
|
574
|
+
_cancelled: true,
|
|
575
|
+
internal: isInternal,
|
|
576
|
+
sub_agent: data?.sub_agent || null,
|
|
577
|
+
local_callback: false,
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Execute tool locally — framework already approved this
|
|
583
|
+
const startTime = Date.now();
|
|
584
|
+
let result;
|
|
585
|
+
try {
|
|
586
|
+
result = await this.toolExecutor.execute(toolName, args || {}, {
|
|
587
|
+
signal: this._toolAbort?.signal,
|
|
588
|
+
});
|
|
589
|
+
} catch (err) {
|
|
590
|
+
if (err?.name === 'AbortError' || this._cancelled) {
|
|
591
|
+
result = { success: false, output: 'Cancelled by user', _cancelled: true };
|
|
592
|
+
} else {
|
|
593
|
+
result = { success: false, output: `Tool execution error: ${err.message}` };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const durationMs = Date.now() - startTime;
|
|
597
|
+
|
|
598
|
+
if (this.verbose) {
|
|
599
|
+
const status = result.success ? 'OK' : 'FAIL';
|
|
600
|
+
process.stderr.write(`\x1b[2m[tool] ${toolName} → ${status} (${durationMs}ms)\x1b[0m\n`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// POST callback to backend
|
|
604
|
+
let callbackPosted = false;
|
|
605
|
+
if (!this._cancelled && !result?._cancelled && this.currentTaskId && callId) {
|
|
606
|
+
callbackPosted = await sendCallback(this.baseUrl, this.token, this.currentTaskId, callId, result);
|
|
607
|
+
telemetry.track(callbackPosted ? 'tool.callback.posted' : 'tool.callback.failed', {
|
|
608
|
+
task_id: this.currentTaskId,
|
|
609
|
+
call_id: callId,
|
|
610
|
+
tool: toolName,
|
|
611
|
+
success: Boolean(result?.success !== false),
|
|
612
|
+
duration_ms: durationMs,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
type: EVENT_TYPES.TOOL_RESULT,
|
|
618
|
+
data: {
|
|
619
|
+
...result,
|
|
620
|
+
call_id: callId,
|
|
621
|
+
tool: toolName,
|
|
622
|
+
args: args || {},
|
|
623
|
+
llm_content: llmToolResultContent(toolName, result),
|
|
624
|
+
duration_ms: durationMs,
|
|
625
|
+
internal: isInternal,
|
|
626
|
+
sub_agent: data?.sub_agent || null,
|
|
627
|
+
local_callback: true,
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Handle framework HITL approval_required event.
|
|
634
|
+
* Shows the same approval menu as tool_call, but POSTs the decision
|
|
635
|
+
* to /api/approval_callback instead of skipping the tool.
|
|
636
|
+
*
|
|
637
|
+
* @param {Object} data - { tool_id, tool, args, risk, reason }
|
|
638
|
+
* @returns {Object|null} optional status event to yield
|
|
639
|
+
*/
|
|
640
|
+
async _handleApprovalRequired(data) {
|
|
641
|
+
const { tool_id, tool, args, risk, reason } = data;
|
|
642
|
+
|
|
643
|
+
if (this.verbose) {
|
|
644
|
+
process.stderr.write(`\x1b[2m[hitl] Approval needed: ${tool} (${risk})\x1b[0m\n`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Use the same ApprovalManager for consistent UX
|
|
648
|
+
const { approved, reason: denyReason, scope: approvedScope } = await this.approval.check(
|
|
649
|
+
tool,
|
|
650
|
+
args || {},
|
|
651
|
+
true,
|
|
652
|
+
{ risk, reason },
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
// Map ApprovalManager decision to framework scope
|
|
656
|
+
let decision, scope;
|
|
657
|
+
if (approved) {
|
|
658
|
+
decision = 'grant';
|
|
659
|
+
// Determine scope from ApprovalManager state
|
|
660
|
+
if (this.approval.approveAll) {
|
|
661
|
+
scope = 'all';
|
|
662
|
+
} else if (this.approval.approvedToolTypes.has(tool)) {
|
|
663
|
+
scope = 'type';
|
|
664
|
+
} else if (approvedScope) {
|
|
665
|
+
scope = String(approvedScope).toLowerCase();
|
|
666
|
+
} else {
|
|
667
|
+
scope = 'once';
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
decision = 'deny';
|
|
671
|
+
scope = 'once';
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// POST decision to backend
|
|
675
|
+
if (this.currentTaskId && tool_id) {
|
|
676
|
+
const posted = await sendApprovalDecision(
|
|
677
|
+
this.baseUrl, this.token, this.currentTaskId,
|
|
678
|
+
tool_id, decision, scope, denyReason || '',
|
|
679
|
+
);
|
|
680
|
+
telemetry.track(posted ? 'approval.callback.posted' : 'approval.callback.failed', {
|
|
681
|
+
task_id: this.currentTaskId,
|
|
682
|
+
tool_id,
|
|
683
|
+
tool,
|
|
684
|
+
decision,
|
|
685
|
+
});
|
|
686
|
+
if (!posted) {
|
|
687
|
+
return {
|
|
688
|
+
type: EVENT_TYPES.ERROR,
|
|
689
|
+
data: {
|
|
690
|
+
message: `Approval for ${tool} could not be applied. The request may have timed out or the stream may be stale; retry the command if it did not continue.`,
|
|
691
|
+
code: 'approval_callback_failed',
|
|
692
|
+
fatal: false,
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (!approved) {
|
|
699
|
+
return {
|
|
700
|
+
type: EVENT_TYPES.STATUS,
|
|
701
|
+
data: { message: `Denied ${tool}: ${denyReason || 'rejected'}` },
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return null; // Approved — framework continues with tool execution
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Cancel the current stream. */
|
|
709
|
+
async cancel() {
|
|
710
|
+
this._cancelled = true;
|
|
711
|
+
this._paused = false;
|
|
712
|
+
this._releasePauseWaiters();
|
|
713
|
+
// Stop local work first. The backend POST below is best-effort and
|
|
714
|
+
// must not delay returning control to the terminal.
|
|
715
|
+
if (this._toolAbort) {
|
|
716
|
+
try { this._toolAbort.abort(); } catch {}
|
|
717
|
+
}
|
|
718
|
+
if (this._abort) {
|
|
719
|
+
try { this._abort.abort(); } catch {}
|
|
720
|
+
}
|
|
721
|
+
// Best-effort backend POST — the stream may already be torn down.
|
|
722
|
+
if (this.currentTaskId) {
|
|
723
|
+
fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
|
|
724
|
+
method: 'POST',
|
|
725
|
+
headers: this._headers(),
|
|
726
|
+
}).catch(() => {});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Pause the current stream. */
|
|
731
|
+
async pause() {
|
|
732
|
+
this._paused = true;
|
|
733
|
+
if (this.currentTaskId) {
|
|
734
|
+
try {
|
|
735
|
+
await fetch(`${this.baseUrl}/api/pause/${this.currentTaskId}`, {
|
|
736
|
+
method: 'POST',
|
|
737
|
+
headers: this._headers(),
|
|
738
|
+
});
|
|
739
|
+
} catch { /* best effort */ }
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Resume a paused stream. */
|
|
744
|
+
async resume(instruction = null) {
|
|
745
|
+
this._paused = false;
|
|
746
|
+
this._releasePauseWaiters();
|
|
747
|
+
if (this.currentTaskId) {
|
|
748
|
+
const body = instruction ? JSON.stringify({ instruction }) : undefined;
|
|
749
|
+
try {
|
|
750
|
+
await fetch(`${this.baseUrl}/api/resume/${this.currentTaskId}`, {
|
|
751
|
+
method: 'POST',
|
|
752
|
+
headers: this._headers({
|
|
753
|
+
'Content-Type': 'application/json',
|
|
754
|
+
}),
|
|
755
|
+
body,
|
|
756
|
+
});
|
|
757
|
+
} catch { /* best effort */ }
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async _waitIfPaused() {
|
|
762
|
+
while (this._paused && !this._cancelled) {
|
|
763
|
+
await new Promise(resolve => this._pauseWaiters.add(resolve));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
_releasePauseWaiters() {
|
|
768
|
+
const waiters = [...this._pauseWaiters];
|
|
769
|
+
this._pauseWaiters.clear();
|
|
770
|
+
for (const resolve of waiters) {
|
|
771
|
+
try { resolve(); } catch { /* ignore */ }
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Summarize a prior transcript for resume continuity.
|
|
777
|
+
*
|
|
778
|
+
* @param {Array<{role:string,content:string}>} messages
|
|
779
|
+
* @param {Object} [opts]
|
|
780
|
+
* @returns {Promise<{summary:string,source:string,model:string}>}
|
|
781
|
+
*/
|
|
782
|
+
async summarizeSession(messages, opts = {}) {
|
|
783
|
+
const response = await fetch(`${this.baseUrl}/api/summarize/session`, {
|
|
784
|
+
method: 'POST',
|
|
785
|
+
headers: this._headers({
|
|
786
|
+
'Content-Type': 'application/json',
|
|
787
|
+
'Accept': 'application/json',
|
|
788
|
+
}),
|
|
789
|
+
body: JSON.stringify({
|
|
790
|
+
messages,
|
|
791
|
+
session_id: opts.sessionId || null,
|
|
792
|
+
project_path: opts.projectPath || null,
|
|
793
|
+
max_tokens: opts.maxTokens || 800,
|
|
794
|
+
}),
|
|
795
|
+
signal: AbortSignal.timeout(opts.timeoutMs || 15000),
|
|
796
|
+
});
|
|
797
|
+
if (!response.ok) {
|
|
798
|
+
const text = await response.text().catch(() => '');
|
|
799
|
+
throw new Error(`summary request failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ''}`);
|
|
800
|
+
}
|
|
801
|
+
return await response.json();
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function fileDiffEventsForToolResult(toolEvent) {
|
|
806
|
+
const data = toolEvent?.data || {};
|
|
807
|
+
const diffs = Array.isArray(data.file_diffs)
|
|
808
|
+
? data.file_diffs
|
|
809
|
+
: data.file_diff ? [data.file_diff] : [];
|
|
810
|
+
if (!diffs.length) return [];
|
|
811
|
+
|
|
812
|
+
return diffs.map((diff, index) => ({
|
|
813
|
+
type: EVENT_TYPES.FILE_DIFF,
|
|
814
|
+
data: {
|
|
815
|
+
call_id: data.call_id || data._callId || null,
|
|
816
|
+
tool: data.tool || data._tool || '',
|
|
817
|
+
index,
|
|
818
|
+
count: diffs.length,
|
|
819
|
+
path: diff.path || '',
|
|
820
|
+
relative_path: diff.relative_path || '',
|
|
821
|
+
lines_added: diff.lines_added || 0,
|
|
822
|
+
lines_removed: diff.lines_removed || 0,
|
|
823
|
+
truncated: !!diff.truncated,
|
|
824
|
+
truncated_line_count: diff.truncated_line_count || 0,
|
|
825
|
+
hunks: diff.hunks || [],
|
|
826
|
+
unified: diff.unified || '',
|
|
827
|
+
},
|
|
828
|
+
}));
|
|
829
|
+
}
|