@bahulam/code 0.1.1
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 +93 -0
- package/package.json +56 -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 +223 -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 +312 -0
- package/src/commands/agent.mjs +221 -0
- package/src/commands/workflow.mjs +581 -0
- package/src/config/cli-args.mjs +202 -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/model-catalog.mjs +57 -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/prose-chunker.mjs +255 -0
- package/src/context/retriever.mjs +425 -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 +145 -0
- package/src/core/approval.mjs +700 -0
- package/src/core/attachments.mjs +666 -0
- package/src/core/backend-url.mjs +68 -0
- package/src/core/bundled-runtime.mjs +418 -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 +331 -0
- package/src/core/file-diff.mjs +217 -0
- package/src/core/headless.mjs +460 -0
- package/src/core/hooks-manager.mjs +87 -0
- package/src/core/jsonl-writer.mjs +449 -0
- package/src/core/local-agent.mjs +538 -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 +388 -0
- package/src/core/safety.mjs +260 -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 +957 -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 +2231 -0
- package/src/core/trust.mjs +160 -0
- package/src/core/work-scope.mjs +248 -0
- package/src/hooks/engine.mjs +162 -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 +374 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/command-classifier.mjs +700 -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 +122 -0
- package/src/terminal/agents.mjs +353 -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 +310 -0
- package/src/terminal/repl-ask-form.mjs +120 -0
- package/src/terminal/repl-explore.mjs +44 -0
- package/src/terminal/repl-format.mjs +317 -0
- package/src/terminal/repl-model-form.mjs +132 -0
- package/src/terminal/repl-render.mjs +833 -0
- package/src/terminal/repl-resume.mjs +640 -0
- package/src/terminal/repl-state.mjs +120 -0
- package/src/terminal/repl-utils.mjs +34 -0
- package/src/terminal/repl.mjs +5032 -0
- package/src/terminal/skills.mjs +54 -0
- package/src/terminal/tool-display.mjs +392 -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 +703 -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 +510 -0
- package/src/ui/banner.mjs +232 -0
- package/src/ui/commands.mjs +537 -0
- package/src/ui/formatter.mjs +409 -0
- package/src/ui/icons.mjs +170 -0
- package/src/ui/input-dock.mjs +772 -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/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +257 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/sub-agent.mjs +167 -0
- package/src/ui/term.mjs +174 -0
- package/src/ui/text-layout.mjs +127 -0
- package/src/ui/tool-card.mjs +740 -0
- package/src/ui/tool-details.mjs +504 -0
- package/src/ui/transcript-block.mjs +20 -0
|
@@ -0,0 +1,957 @@
|
|
|
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
|
+
// Live steering (PRD-081 §5.2)
|
|
57
|
+
USER_INTERVENTION_ACCEPTED: 'user_intervention_accepted',
|
|
58
|
+
USER_INTERVENTION_DELIVERED: 'user_intervention_delivered',
|
|
59
|
+
USER_INTERVENTION_QUEUED: 'user_intervention_queued',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Lightweight UUID-ish generator for intervention ids. Node ≥18 has
|
|
63
|
+
// crypto.randomUUID but the fallback avoids importing node:crypto here.
|
|
64
|
+
function _uuidLike() {
|
|
65
|
+
try {
|
|
66
|
+
// eslint-disable-next-line no-undef
|
|
67
|
+
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
|
|
68
|
+
return globalThis.crypto.randomUUID();
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
const rand = () => Math.random().toString(16).slice(2, 10);
|
|
72
|
+
return `iv-${Date.now().toString(36)}-${rand()}${rand()}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sleep(ms) {
|
|
76
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Full jitter around the scheduled delay: pick a value in [delay*0.5, delay*1.5].
|
|
80
|
+
// Spreads out reconnect storms so N clients dropping simultaneously don't
|
|
81
|
+
// synchronize their retries. Clamped to the same 30s ceiling as the base delay.
|
|
82
|
+
function jitteredDelay(delayMs) {
|
|
83
|
+
const min = Math.floor(delayMs * 0.5);
|
|
84
|
+
const max = Math.floor(delayMs * 1.5);
|
|
85
|
+
const jittered = min + Math.floor(Math.random() * (max - min + 1));
|
|
86
|
+
return Math.max(200, Math.min(30_000, jittered));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Categorize a fetch error. "offline-class" means DNS/routing failures that
|
|
90
|
+
// almost never recover within a few seconds — we bail after 2 in a row rather
|
|
91
|
+
// than burn the full 5-min budget when the user's network is truly down.
|
|
92
|
+
function isOfflineLikelyError(err) {
|
|
93
|
+
const code = err?.cause?.code || err?.code || '';
|
|
94
|
+
return (
|
|
95
|
+
code === 'ENOTFOUND' || // DNS lookup failed
|
|
96
|
+
code === 'EAI_AGAIN' || // DNS temporary failure
|
|
97
|
+
code === 'ENETUNREACH' || // no route to host
|
|
98
|
+
code === 'EHOSTUNREACH' || // host unreachable
|
|
99
|
+
code === 'UND_ERR_CONNECT_TIMEOUT' // undici connect timeout
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class TarangStreamClient {
|
|
104
|
+
/**
|
|
105
|
+
* @param {Object} opts
|
|
106
|
+
* @param {string} opts.baseUrl - Tarang backend URL (ignored when mode='bundled')
|
|
107
|
+
* @param {string} opts.token - CLI auth token
|
|
108
|
+
* @param {Object} opts.toolExecutor - { execute(name, args) }
|
|
109
|
+
* @param {boolean} [opts.verbose=false]
|
|
110
|
+
* @param {'remote'|'bundled'} [opts.mode='remote'] - 'bundled' routes all
|
|
111
|
+
* requests through the local bahulam-agent runtime subprocess (PRD-091 §6).
|
|
112
|
+
* 'remote' uses baseUrl and standard fetch (default; matches cloud/dev/prod backend).
|
|
113
|
+
*/
|
|
114
|
+
constructor({
|
|
115
|
+
baseUrl,
|
|
116
|
+
token,
|
|
117
|
+
toolExecutor,
|
|
118
|
+
verbose = false,
|
|
119
|
+
approvalManager = null,
|
|
120
|
+
reconnectMaxElapsedMs = null,
|
|
121
|
+
mode = null,
|
|
122
|
+
}) {
|
|
123
|
+
this.baseUrl = (baseUrl || '').replace(/\/$/, '');
|
|
124
|
+
this.token = token;
|
|
125
|
+
this.toolExecutor = toolExecutor;
|
|
126
|
+
this.verbose = verbose;
|
|
127
|
+
this.approval = approvalManager || new ApprovalManager();
|
|
128
|
+
this.product = 'bahulam';
|
|
129
|
+
this.currentTaskId = null;
|
|
130
|
+
this.lastEventId = null;
|
|
131
|
+
this.retryDelayMs = null;
|
|
132
|
+
this.pendingToolCallbacks = new Map();
|
|
133
|
+
this.reconnectMaxElapsedMs = reconnectMaxElapsedMs
|
|
134
|
+
?? Number(process.env.KEPLER_RECONNECT_MAX_ELAPSED_MS || 300_000);
|
|
135
|
+
// Set by backend on first turn, reused on subsequent turns. Headless mode
|
|
136
|
+
// (which starts fresh per invocation) can pre-seed via TARANG_SESSION_ID
|
|
137
|
+
// so multi-turn benchmarks share one backend session across `node` runs.
|
|
138
|
+
this.sessionId = process.env.TARANG_SESSION_ID || null;
|
|
139
|
+
this._cancelled = false;
|
|
140
|
+
this._paused = false;
|
|
141
|
+
this._pauseWaiters = new Set();
|
|
142
|
+
this._abort = null;
|
|
143
|
+
this._toolAbort = null;
|
|
144
|
+
|
|
145
|
+
// Transport mode:
|
|
146
|
+
// 'bundled' → local Python runtime (PRD-091 §6). Framework calls
|
|
147
|
+
// the Bahulam Gateway directly. Metering runs. THIS IS THE
|
|
148
|
+
// PUBLIC CLI DEFAULT.
|
|
149
|
+
// 'remote' → cloud backend runs the agent loop server-side.
|
|
150
|
+
// Backend calls Bahulam Gateway with service-token attribution;
|
|
151
|
+
// metering still runs at the gateway boundary.
|
|
152
|
+
//
|
|
153
|
+
// Explicit opt precedence: constructor arg > env vars > sniff runtime
|
|
154
|
+
// package availability > default 'bundled'.
|
|
155
|
+
this.mode = mode
|
|
156
|
+
|| (process.env.BAHULAM_RUNTIME_MODE === 'remote' ? 'remote' : null)
|
|
157
|
+
|| (process.env.BAHULAM_RUNTIME_MODE === 'bundled' ? 'bundled' : null)
|
|
158
|
+
|| (process.env.TARANG_ENV === 'remote' ? 'remote' : null)
|
|
159
|
+
|| (process.env.TARANG_ENV === 'bundled' ? 'bundled' : null)
|
|
160
|
+
|| 'bundled';
|
|
161
|
+
// Bundled runtime binds to a random localhost port on first use. Cached
|
|
162
|
+
// here so every method sees the same baseUrl without re-spawning.
|
|
163
|
+
this._bundledReady = false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Ensure the bundled runtime is spawned and this.baseUrl points at it.
|
|
168
|
+
* No-op in remote mode. Callers that hit the backend should invoke this
|
|
169
|
+
* at the top of their method (idempotent, cheap after the first call).
|
|
170
|
+
*/
|
|
171
|
+
async _ensureBundledRuntime() {
|
|
172
|
+
if (this.mode !== 'bundled' || this._bundledReady) return;
|
|
173
|
+
const { ensureRuntimeReady } = await import('./bundled-runtime.mjs');
|
|
174
|
+
const info = await ensureRuntimeReady();
|
|
175
|
+
// Runtime speaks the same HTTP shape as the cloud backend, just on a
|
|
176
|
+
// random localhost port. Overriding baseUrl means every existing
|
|
177
|
+
// ${this.baseUrl}/api/* fetch call keeps working unchanged.
|
|
178
|
+
this.baseUrl = info.baseUrl;
|
|
179
|
+
this._bundledReady = true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_headers(extra = {}) {
|
|
183
|
+
const headers = { ...extra };
|
|
184
|
+
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
185
|
+
if (this.product) headers['X-Product'] = this.product;
|
|
186
|
+
return headers;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async analyzeVision({ instruction, attachments }) {
|
|
190
|
+
const url = `${this.baseUrl}/api/vision/analyze`;
|
|
191
|
+
const body = { instruction, attachments: attachments || [] };
|
|
192
|
+
const response = await fetch(url, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: this._headers({ 'Content-Type': 'application/json' }),
|
|
195
|
+
body: JSON.stringify(body),
|
|
196
|
+
});
|
|
197
|
+
const text = await response.text().catch(() => '');
|
|
198
|
+
let payload = null;
|
|
199
|
+
try {
|
|
200
|
+
payload = text ? JSON.parse(text) : null;
|
|
201
|
+
} catch {
|
|
202
|
+
payload = { detail: text };
|
|
203
|
+
}
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
const detail = payload?.detail || payload || {};
|
|
206
|
+
const message = typeof detail === 'string'
|
|
207
|
+
? detail
|
|
208
|
+
: detail.message || detail.error || `Vision analysis failed (${response.status})`;
|
|
209
|
+
const err = new Error(message);
|
|
210
|
+
err.status = response.status;
|
|
211
|
+
err.detail = detail;
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
return payload || {};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Execute an instruction via SSE stream.
|
|
219
|
+
* Yields parsed events. Client-side tool requests are shown, executed
|
|
220
|
+
* locally, callback-posted, then followed by a local tool_result event.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} instruction
|
|
223
|
+
* @param {Object} [context={}]
|
|
224
|
+
* @param {string} [model]
|
|
225
|
+
* @yields {{ type: string, data: Object }}
|
|
226
|
+
*/
|
|
227
|
+
async *execute(instruction, context = {}, messages = null) {
|
|
228
|
+
this._cancelled = false;
|
|
229
|
+
this.currentTaskId = null;
|
|
230
|
+
|
|
231
|
+
// Bundled mode: spawn the local Python runtime on first turn.
|
|
232
|
+
// After this, this.baseUrl points at http://127.0.0.1:<random-port>
|
|
233
|
+
// and every existing fetch call in this class works unchanged.
|
|
234
|
+
await this._ensureBundledRuntime();
|
|
235
|
+
|
|
236
|
+
const url = `${this.baseUrl}/api/execute`;
|
|
237
|
+
const body = { instruction, context };
|
|
238
|
+
if (messages && messages.length > 0) body.messages = messages;
|
|
239
|
+
if (this.sessionId) body.session_id = this.sessionId;
|
|
240
|
+
|
|
241
|
+
const headers = this._headers({
|
|
242
|
+
'Accept': 'text/event-stream',
|
|
243
|
+
'Content-Type': 'application/json',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Abort controller so cancel() can break out of a stalled reader
|
|
247
|
+
// instead of waiting for the next SSE event to notice _cancelled.
|
|
248
|
+
this._abort = new AbortController();
|
|
249
|
+
this._toolAbort = new AbortController();
|
|
250
|
+
|
|
251
|
+
let response;
|
|
252
|
+
try {
|
|
253
|
+
response = await fetch(url, {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
headers,
|
|
256
|
+
body: JSON.stringify(body),
|
|
257
|
+
signal: this._abort.signal,
|
|
258
|
+
});
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (err.name === 'AbortError') {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, fatal: true } };
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (response.status === 401) {
|
|
268
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: 'Authentication failed. Run `bahulam login` to re-authenticate.', fatal: true } };
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (response.status === 429) {
|
|
272
|
+
const text = await response.text().catch(() => '');
|
|
273
|
+
let payload = null;
|
|
274
|
+
try {
|
|
275
|
+
payload = text ? JSON.parse(text) : null;
|
|
276
|
+
} catch {
|
|
277
|
+
payload = { detail: { message: text } };
|
|
278
|
+
}
|
|
279
|
+
const detail = quotaErrorDetail(payload);
|
|
280
|
+
yield {
|
|
281
|
+
type: EVENT_TYPES.ERROR,
|
|
282
|
+
data: {
|
|
283
|
+
message: rateLimitErrorMessage(payload),
|
|
284
|
+
code: detail?.code || 'rate_limited',
|
|
285
|
+
retry_after: detail?.retry_after ?? detail?.rate_limit?.retry_after,
|
|
286
|
+
rate_limit: detail?.rate_limit || null,
|
|
287
|
+
action: detail?.action || null,
|
|
288
|
+
pricing_url: normalizeBillingBrandCopy(detail?.pricing_url || null),
|
|
289
|
+
fatal: true,
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!response.ok) {
|
|
295
|
+
const text = await response.text().catch(() => 'Unknown error');
|
|
296
|
+
yield { type: EVENT_TYPES.ERROR, data: { message: `Backend error ${response.status}: ${text}`, fatal: true } };
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
yield* this._consumeResponse(response);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if (this._cancelled) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
yield* this._reconnectAfterDrop(err);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async *_consumeResponse(response) {
|
|
311
|
+
const taskId = response.headers.get('X-Task-ID');
|
|
312
|
+
if (taskId) this.currentTaskId = taskId;
|
|
313
|
+
|
|
314
|
+
for await (const parsed of this._parseSSE(response)) {
|
|
315
|
+
if (this._cancelled) return;
|
|
316
|
+
await this._waitIfPaused();
|
|
317
|
+
if (this._cancelled) return;
|
|
318
|
+
|
|
319
|
+
if (parsed.id != null) this.lastEventId = parsed.id;
|
|
320
|
+
if (parsed.retry != null) this.retryDelayMs = parsed.retry;
|
|
321
|
+
if (parsed.data?.task_id) this.currentTaskId = parsed.data.task_id;
|
|
322
|
+
|
|
323
|
+
for await (const event of this._handleStreamEvent(parsed)) {
|
|
324
|
+
yield event;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async *consumeEventStream(response) {
|
|
330
|
+
yield* this._consumeResponse(response);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async *_handleStreamEvent({ event, data, id = null, retry = null }) {
|
|
334
|
+
const rendered = { type: event, data, event_id: id, retry };
|
|
335
|
+
|
|
336
|
+
// Capture session_id from backend (first turn creates it, subsequent turns reuse)
|
|
337
|
+
if (event === EVENT_TYPES.SESSION_INFO && data?.session_id) {
|
|
338
|
+
this.sessionId = data.session_id;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Framework HITL: approval_required — show menu, POST decision
|
|
342
|
+
if (event === EVENT_TYPES.APPROVAL_REQUIRED) {
|
|
343
|
+
yield rendered;
|
|
344
|
+
const approvalEvent = await this._handleApprovalRequired(data);
|
|
345
|
+
if (approvalEvent) yield approvalEvent;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (event === EVENT_TYPES.APPROVAL_GRANTED || event === EVENT_TYPES.APPROVAL_DENIED) {
|
|
350
|
+
yield rendered;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Tool requests — show to user, then execute locally and POST callback.
|
|
355
|
+
if (event === EVENT_TYPES.TOOL_REQUEST || event === EVENT_TYPES.TOOL_CALL) {
|
|
356
|
+
yield rendered;
|
|
357
|
+
await this._waitIfPaused();
|
|
358
|
+
if (this._cancelled || data?.server_side) return;
|
|
359
|
+
const toolEvent = await this._handleToolRequest(data);
|
|
360
|
+
if (toolEvent) {
|
|
361
|
+
await this._waitIfPaused();
|
|
362
|
+
if (this._cancelled) return;
|
|
363
|
+
yield toolEvent;
|
|
364
|
+
for (const diffEvent of fileDiffEventsForToolResult(toolEvent)) {
|
|
365
|
+
yield diffEvent;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
yield rendered;
|
|
372
|
+
if (event === EVENT_TYPES.TOOL_RESULT || event === EVENT_TYPES.TOOL_DONE) {
|
|
373
|
+
for (const diffEvent of fileDiffEventsForToolResult(rendered)) {
|
|
374
|
+
yield diffEvent;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async *_reconnectAfterDrop(err) {
|
|
380
|
+
const taskId = this.currentTaskId;
|
|
381
|
+
if (!taskId || this.lastEventId == null) {
|
|
382
|
+
yield {
|
|
383
|
+
type: EVENT_TYPES.ERROR,
|
|
384
|
+
data: {
|
|
385
|
+
message: `Stream interrupted: ${err?.message || 'connection lost'}. Use /resume to continue from saved history.`,
|
|
386
|
+
fatal: true,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const started = Date.now();
|
|
393
|
+
let attempt = 0;
|
|
394
|
+
// Slow-start baseline: 500ms for the first retry catches most
|
|
395
|
+
// transient blips before the user notices; the ramp doubles from
|
|
396
|
+
// there. Server-hinted `retry:` from the last SSE frame overrides
|
|
397
|
+
// if present. Capped at 30s.
|
|
398
|
+
let delayMs = Math.max(200, Math.min(this.retryDelayMs || 500, 30_000));
|
|
399
|
+
let offlineStreak = 0;
|
|
400
|
+
|
|
401
|
+
while (!this._cancelled && Date.now() - started < this.reconnectMaxElapsedMs) {
|
|
402
|
+
attempt++;
|
|
403
|
+
const after = this.lastEventId;
|
|
404
|
+
// Apply jitter to the scheduled delay so N clients dropping at
|
|
405
|
+
// the same time don't lockstep-retry against the server. The
|
|
406
|
+
// event surfaces the ACTUAL wait so the CLI can show it.
|
|
407
|
+
const waitMs = jitteredDelay(delayMs);
|
|
408
|
+
telemetry.track('stream.reconnect.attempt', {
|
|
409
|
+
task_id: taskId,
|
|
410
|
+
after,
|
|
411
|
+
attempt,
|
|
412
|
+
base_delay_ms: delayMs,
|
|
413
|
+
delay_ms: waitMs,
|
|
414
|
+
});
|
|
415
|
+
yield {
|
|
416
|
+
type: EVENT_TYPES.RECONNECTING,
|
|
417
|
+
data: { task_id: taskId, after, attempt, delay_ms: waitMs },
|
|
418
|
+
};
|
|
419
|
+
await sleep(waitMs);
|
|
420
|
+
try {
|
|
421
|
+
const url = `${this.baseUrl}/api/execute/${encodeURIComponent(taskId)}/events?after=${encodeURIComponent(after)}`;
|
|
422
|
+
const response = await fetch(url, {
|
|
423
|
+
method: 'GET',
|
|
424
|
+
headers: this._headers({ Accept: 'text/event-stream' }),
|
|
425
|
+
signal: AbortSignal.timeout(30_000),
|
|
426
|
+
});
|
|
427
|
+
if (response.status === 404 || response.status === 410) {
|
|
428
|
+
telemetry.track('stream.reconnect.failed', {
|
|
429
|
+
task_id: taskId,
|
|
430
|
+
after,
|
|
431
|
+
attempt,
|
|
432
|
+
status: response.status,
|
|
433
|
+
code: response.status === 410 ? 'reconnect_buffer_expired' : 'task_not_found',
|
|
434
|
+
retryable: false,
|
|
435
|
+
});
|
|
436
|
+
yield {
|
|
437
|
+
type: EVENT_TYPES.RECONNECT_FAILED,
|
|
438
|
+
data: {
|
|
439
|
+
task_id: taskId,
|
|
440
|
+
code: response.status === 410 ? 'reconnect_buffer_expired' : 'task_not_found',
|
|
441
|
+
retryable: false,
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (!response.ok) throw new Error(`reconnect failed (${response.status})`);
|
|
447
|
+
let replayed = 0;
|
|
448
|
+
for await (const event of this._consumeResponse(response)) {
|
|
449
|
+
if (event.type !== EVENT_TYPES.RECONNECTED) replayed++;
|
|
450
|
+
if (event.type === EVENT_TYPES.RECONNECTED) {
|
|
451
|
+
replayed = Number(event.data?.replayed ?? replayed);
|
|
452
|
+
}
|
|
453
|
+
yield event;
|
|
454
|
+
}
|
|
455
|
+
telemetry.track('stream.reconnect.succeeded', {
|
|
456
|
+
task_id: taskId,
|
|
457
|
+
after,
|
|
458
|
+
attempt,
|
|
459
|
+
replayed,
|
|
460
|
+
elapsed_ms: Date.now() - started,
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
} catch (nextErr) {
|
|
464
|
+
if (this._cancelled) return;
|
|
465
|
+
// Offline-class errors (DNS failure, no route) rarely recover
|
|
466
|
+
// in seconds. Bail after 2 in a row so we don't burn the full
|
|
467
|
+
// 5-min budget spinning against a dead network — the user
|
|
468
|
+
// gets an actionable "network unreachable" message instead.
|
|
469
|
+
if (isOfflineLikelyError(nextErr)) {
|
|
470
|
+
offlineStreak++;
|
|
471
|
+
if (offlineStreak >= 2) {
|
|
472
|
+
telemetry.track('stream.reconnect.failed', {
|
|
473
|
+
task_id: taskId,
|
|
474
|
+
after,
|
|
475
|
+
attempt,
|
|
476
|
+
code: 'network_unreachable',
|
|
477
|
+
error_code: nextErr?.cause?.code || nextErr?.code || '',
|
|
478
|
+
retryable: true,
|
|
479
|
+
});
|
|
480
|
+
yield {
|
|
481
|
+
type: EVENT_TYPES.RECONNECT_FAILED,
|
|
482
|
+
data: {
|
|
483
|
+
task_id: taskId,
|
|
484
|
+
after,
|
|
485
|
+
code: 'network_unreachable',
|
|
486
|
+
message: 'Network appears unreachable — DNS lookup failed twice. Check your connection, then /resume to continue.',
|
|
487
|
+
retryable: true,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
offlineStreak = 0;
|
|
494
|
+
}
|
|
495
|
+
telemetry.track('stream.reconnect.retry', {
|
|
496
|
+
task_id: taskId,
|
|
497
|
+
after,
|
|
498
|
+
attempt,
|
|
499
|
+
message: nextErr?.message || 'reconnect failed',
|
|
500
|
+
error_code: nextErr?.cause?.code || nextErr?.code || '',
|
|
501
|
+
});
|
|
502
|
+
err = nextErr;
|
|
503
|
+
delayMs = Math.min(delayMs * 2, 30_000);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
telemetry.track('stream.reconnect.failed', {
|
|
508
|
+
task_id: taskId,
|
|
509
|
+
after: this.lastEventId,
|
|
510
|
+
code: 'reconnect_timeout',
|
|
511
|
+
retryable: false,
|
|
512
|
+
elapsed_ms: Date.now() - started,
|
|
513
|
+
});
|
|
514
|
+
yield {
|
|
515
|
+
type: EVENT_TYPES.RECONNECT_FAILED,
|
|
516
|
+
data: {
|
|
517
|
+
task_id: taskId,
|
|
518
|
+
after: this.lastEventId,
|
|
519
|
+
code: 'reconnect_timeout',
|
|
520
|
+
message: err?.message || 'connection lost',
|
|
521
|
+
retryable: false,
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Parse SSE from a fetch Response using ReadableStream.
|
|
528
|
+
* @param {Response} response
|
|
529
|
+
* @yields {{ event: string, data: Object, id: string|null, retry: number|null }}
|
|
530
|
+
*/
|
|
531
|
+
async *_parseSSE(response) {
|
|
532
|
+
const reader = response.body.getReader();
|
|
533
|
+
const decoder = new TextDecoder();
|
|
534
|
+
let buffer = '';
|
|
535
|
+
let currentEvent = 'message';
|
|
536
|
+
let currentData = [];
|
|
537
|
+
let currentId = null;
|
|
538
|
+
let currentRetry = null;
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
while (true) {
|
|
542
|
+
let read;
|
|
543
|
+
try {
|
|
544
|
+
read = await reader.read();
|
|
545
|
+
} catch (err) {
|
|
546
|
+
// Aborted via cancel() — treat as a clean end-of-stream.
|
|
547
|
+
if (err && (err.name === 'AbortError' || this._cancelled)) break;
|
|
548
|
+
throw err;
|
|
549
|
+
}
|
|
550
|
+
const { done, value } = read;
|
|
551
|
+
if (done) break;
|
|
552
|
+
|
|
553
|
+
buffer += decoder.decode(value, { stream: true });
|
|
554
|
+
const lines = buffer.split('\n');
|
|
555
|
+
buffer = lines.pop(); // keep incomplete last line
|
|
556
|
+
|
|
557
|
+
for (const line of lines) {
|
|
558
|
+
const rawLine = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
559
|
+
const trimmed = rawLine.trim();
|
|
560
|
+
|
|
561
|
+
if (!trimmed) {
|
|
562
|
+
// Empty line = event boundary
|
|
563
|
+
if (currentData.length > 0) {
|
|
564
|
+
const rawData = currentData.join('\n');
|
|
565
|
+
let parsed;
|
|
566
|
+
try {
|
|
567
|
+
parsed = JSON.parse(rawData);
|
|
568
|
+
} catch {
|
|
569
|
+
parsed = { message: rawData };
|
|
570
|
+
}
|
|
571
|
+
yield { event: currentEvent, data: parsed, id: currentId, retry: currentRetry };
|
|
572
|
+
}
|
|
573
|
+
currentEvent = 'message';
|
|
574
|
+
currentData = [];
|
|
575
|
+
currentId = null;
|
|
576
|
+
currentRetry = null;
|
|
577
|
+
} else if (trimmed.startsWith('event:')) {
|
|
578
|
+
currentEvent = trimmed.slice(6).trim();
|
|
579
|
+
} else if (trimmed.startsWith('data:')) {
|
|
580
|
+
currentData.push(trimmed.slice(5).trim());
|
|
581
|
+
} else if (trimmed.startsWith('id:')) {
|
|
582
|
+
currentId = trimmed.slice(3).trim();
|
|
583
|
+
} else if (trimmed.startsWith('retry:')) {
|
|
584
|
+
const parsedRetry = Number(trimmed.slice(6).trim());
|
|
585
|
+
if (Number.isFinite(parsedRetry) && parsedRetry >= 0) currentRetry = parsedRetry;
|
|
586
|
+
}
|
|
587
|
+
// comments and unknown SSE fields are ignored.
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Flush remaining
|
|
592
|
+
if (currentData.length > 0) {
|
|
593
|
+
const rawData = currentData.join('\n');
|
|
594
|
+
let parsed;
|
|
595
|
+
try {
|
|
596
|
+
parsed = JSON.parse(rawData);
|
|
597
|
+
} catch {
|
|
598
|
+
parsed = { message: rawData };
|
|
599
|
+
}
|
|
600
|
+
yield { event: currentEvent, data: parsed, id: currentId, retry: currentRetry };
|
|
601
|
+
}
|
|
602
|
+
} finally {
|
|
603
|
+
reader.releaseLock();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Handle a tool_call event: execute tool locally, POST result via callback.
|
|
609
|
+
*
|
|
610
|
+
* Approval is handled by the framework (HITL) BEFORE this is called.
|
|
611
|
+
* By the time a tool_call arrives here, it's already approved.
|
|
612
|
+
*
|
|
613
|
+
* @param {Object} data - { call_id, tool, args }
|
|
614
|
+
* @returns {Object} local tool result event to yield
|
|
615
|
+
*/
|
|
616
|
+
async _handleToolRequest(data) {
|
|
617
|
+
const { call_id, request_id, tool, args } = data;
|
|
618
|
+
const callId = call_id || request_id;
|
|
619
|
+
const toolName = tool;
|
|
620
|
+
const isInternal = Boolean(data?.internal || data?.sub_agent);
|
|
621
|
+
|
|
622
|
+
if (this.verbose) {
|
|
623
|
+
process.stderr.write(`\x1b[2m[tool] ${toolName}(${JSON.stringify(args).slice(0, 80)}...)\x1b[0m\n`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (this._cancelled) {
|
|
627
|
+
return {
|
|
628
|
+
type: EVENT_TYPES.TOOL_RESULT,
|
|
629
|
+
data: {
|
|
630
|
+
success: false,
|
|
631
|
+
output: 'Cancelled by user',
|
|
632
|
+
call_id: callId,
|
|
633
|
+
tool: toolName,
|
|
634
|
+
args: args || {},
|
|
635
|
+
_cancelled: true,
|
|
636
|
+
internal: isInternal,
|
|
637
|
+
sub_agent: data?.sub_agent || null,
|
|
638
|
+
local_callback: false,
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Execute tool locally — framework already approved this
|
|
644
|
+
const startTime = Date.now();
|
|
645
|
+
let result;
|
|
646
|
+
try {
|
|
647
|
+
result = await this.toolExecutor.execute(toolName, args || {}, {
|
|
648
|
+
signal: this._toolAbort?.signal,
|
|
649
|
+
});
|
|
650
|
+
} catch (err) {
|
|
651
|
+
if (err?.name === 'AbortError' || this._cancelled) {
|
|
652
|
+
result = { success: false, output: 'Cancelled by user', _cancelled: true };
|
|
653
|
+
} else {
|
|
654
|
+
result = { success: false, output: `Tool execution error: ${err.message}` };
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const durationMs = Date.now() - startTime;
|
|
658
|
+
|
|
659
|
+
if (this.verbose) {
|
|
660
|
+
const status = result.success ? 'OK' : 'FAIL';
|
|
661
|
+
process.stderr.write(`\x1b[2m[tool] ${toolName} → ${status} (${durationMs}ms)\x1b[0m\n`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// POST callback to backend
|
|
665
|
+
let callbackPosted = false;
|
|
666
|
+
if (!this._cancelled && !result?._cancelled && this.currentTaskId && callId) {
|
|
667
|
+
callbackPosted = await sendCallback(this.baseUrl, this.token, this.currentTaskId, callId, result);
|
|
668
|
+
telemetry.track(callbackPosted ? 'tool.callback.posted' : 'tool.callback.failed', {
|
|
669
|
+
task_id: this.currentTaskId,
|
|
670
|
+
call_id: callId,
|
|
671
|
+
tool: toolName,
|
|
672
|
+
success: Boolean(result?.success !== false),
|
|
673
|
+
duration_ms: durationMs,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
type: EVENT_TYPES.TOOL_RESULT,
|
|
679
|
+
data: {
|
|
680
|
+
...result,
|
|
681
|
+
call_id: callId,
|
|
682
|
+
tool: toolName,
|
|
683
|
+
args: args || {},
|
|
684
|
+
llm_content: llmToolResultContent(toolName, result),
|
|
685
|
+
duration_ms: durationMs,
|
|
686
|
+
internal: isInternal,
|
|
687
|
+
sub_agent: data?.sub_agent || null,
|
|
688
|
+
local_callback: true,
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Handle framework HITL approval_required event.
|
|
695
|
+
* Shows the same approval menu as tool_call, but POSTs the decision
|
|
696
|
+
* to /api/approval_callback instead of skipping the tool.
|
|
697
|
+
*
|
|
698
|
+
* @param {Object} data - { tool_id, tool, args, risk, reason }
|
|
699
|
+
* @returns {Object|null} optional status event to yield
|
|
700
|
+
*/
|
|
701
|
+
async _handleApprovalRequired(data) {
|
|
702
|
+
const { tool_id, tool, args, risk, reason } = data;
|
|
703
|
+
|
|
704
|
+
if (this.verbose) {
|
|
705
|
+
process.stderr.write(`\x1b[2m[hitl] Approval needed: ${tool} (${risk})\x1b[0m\n`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Use the same ApprovalManager for consistent UX
|
|
709
|
+
const { approved, reason: denyReason, scope: approvedScope } = await this.approval.check(
|
|
710
|
+
tool,
|
|
711
|
+
args || {},
|
|
712
|
+
true,
|
|
713
|
+
{ risk, reason },
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
// Map ApprovalManager decision to framework scope
|
|
717
|
+
let decision, scope;
|
|
718
|
+
if (approved) {
|
|
719
|
+
decision = 'grant';
|
|
720
|
+
// Determine scope from ApprovalManager state
|
|
721
|
+
if (this.approval.approveAll) {
|
|
722
|
+
scope = 'all';
|
|
723
|
+
} else if (this.approval.approvedToolTypes.has(tool)) {
|
|
724
|
+
scope = 'type';
|
|
725
|
+
} else if (approvedScope) {
|
|
726
|
+
scope = String(approvedScope).toLowerCase();
|
|
727
|
+
} else {
|
|
728
|
+
scope = 'once';
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
decision = 'deny';
|
|
732
|
+
scope = 'once';
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// POST decision to backend
|
|
736
|
+
if (this.currentTaskId && tool_id) {
|
|
737
|
+
const posted = await sendApprovalDecision(
|
|
738
|
+
this.baseUrl, this.token, this.currentTaskId,
|
|
739
|
+
tool_id, decision, scope, denyReason || '',
|
|
740
|
+
);
|
|
741
|
+
telemetry.track(posted ? 'approval.callback.posted' : 'approval.callback.failed', {
|
|
742
|
+
task_id: this.currentTaskId,
|
|
743
|
+
tool_id,
|
|
744
|
+
tool,
|
|
745
|
+
decision,
|
|
746
|
+
});
|
|
747
|
+
if (!posted) {
|
|
748
|
+
return {
|
|
749
|
+
type: EVENT_TYPES.ERROR,
|
|
750
|
+
data: {
|
|
751
|
+
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.`,
|
|
752
|
+
code: 'approval_callback_failed',
|
|
753
|
+
fatal: false,
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (!approved) {
|
|
760
|
+
return {
|
|
761
|
+
type: EVENT_TYPES.STATUS,
|
|
762
|
+
data: { message: `Denied ${tool}: ${denyReason || 'rejected'}` },
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return null; // Approved — framework continues with tool execution
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Cancel the current stream. */
|
|
770
|
+
async cancel() {
|
|
771
|
+
this._cancelled = true;
|
|
772
|
+
this._paused = false;
|
|
773
|
+
this._releasePauseWaiters();
|
|
774
|
+
// Stop local work first. The backend POST below is best-effort and
|
|
775
|
+
// must not delay returning control to the terminal.
|
|
776
|
+
if (this._toolAbort) {
|
|
777
|
+
try { this._toolAbort.abort(); } catch {}
|
|
778
|
+
}
|
|
779
|
+
if (this._abort) {
|
|
780
|
+
try { this._abort.abort(); } catch {}
|
|
781
|
+
}
|
|
782
|
+
// Best-effort backend POST — the stream may already be torn down.
|
|
783
|
+
if (this.currentTaskId) {
|
|
784
|
+
fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
|
|
785
|
+
method: 'POST',
|
|
786
|
+
headers: this._headers(),
|
|
787
|
+
}).catch(() => {});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Pause the current stream. */
|
|
792
|
+
async pause() {
|
|
793
|
+
this._paused = true;
|
|
794
|
+
if (this.currentTaskId) {
|
|
795
|
+
try {
|
|
796
|
+
await fetch(`${this.baseUrl}/api/pause/${this.currentTaskId}`, {
|
|
797
|
+
method: 'POST',
|
|
798
|
+
headers: this._headers(),
|
|
799
|
+
});
|
|
800
|
+
} catch { /* best effort */ }
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Resume a paused stream. */
|
|
805
|
+
async resume(instruction = null) {
|
|
806
|
+
this._paused = false;
|
|
807
|
+
this._releasePauseWaiters();
|
|
808
|
+
if (this.currentTaskId) {
|
|
809
|
+
const body = instruction ? JSON.stringify({ instruction }) : undefined;
|
|
810
|
+
try {
|
|
811
|
+
await fetch(`${this.baseUrl}/api/resume/${this.currentTaskId}`, {
|
|
812
|
+
method: 'POST',
|
|
813
|
+
headers: this._headers({
|
|
814
|
+
'Content-Type': 'application/json',
|
|
815
|
+
}),
|
|
816
|
+
body,
|
|
817
|
+
});
|
|
818
|
+
} catch { /* best effort */ }
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Submit a live-steering follow-up on the current running task (PRD-081 §5.2).
|
|
824
|
+
* Unlike resume(), this does NOT pause/unpause — the text is queued and
|
|
825
|
+
* delivered at the next tool boundary.
|
|
826
|
+
*
|
|
827
|
+
* Returns a status object; callers should NOT swallow errors:
|
|
828
|
+
* { status: 'accepted', interventionId } — queued on backend, SSE ack forthcoming
|
|
829
|
+
* { status: 'queued_next_turn', interventionId } — task already ended; hand back as next turn
|
|
830
|
+
* { status: 'duplicate', interventionId } — same intervention_id previously submitted
|
|
831
|
+
* { status: 'no_task' } — no currentTaskId (nothing to steer)
|
|
832
|
+
* { status: 'error', error, httpStatus? } — network or non-2xx response
|
|
833
|
+
*
|
|
834
|
+
* @param {string} instruction Follow-up text (non-empty, trimmed by caller).
|
|
835
|
+
* @param {object} [opts]
|
|
836
|
+
* @param {string} [opts.idempotencyKey] Optional client-generated id.
|
|
837
|
+
* Auto-generated when omitted; pass the same key
|
|
838
|
+
* for retries to stay idempotent.
|
|
839
|
+
*/
|
|
840
|
+
async sendIntervention(instruction, opts = {}) {
|
|
841
|
+
if (!this.currentTaskId) {
|
|
842
|
+
return { status: 'no_task' };
|
|
843
|
+
}
|
|
844
|
+
const text = String(instruction || '').trim();
|
|
845
|
+
if (!text) {
|
|
846
|
+
return { status: 'error', error: 'instruction is empty' };
|
|
847
|
+
}
|
|
848
|
+
const interventionId = opts.idempotencyKey || _uuidLike();
|
|
849
|
+
try {
|
|
850
|
+
const response = await fetch(
|
|
851
|
+
`${this.baseUrl}/api/intervention/${this.currentTaskId}`,
|
|
852
|
+
{
|
|
853
|
+
method: 'POST',
|
|
854
|
+
headers: this._headers({
|
|
855
|
+
'Content-Type': 'application/json',
|
|
856
|
+
}),
|
|
857
|
+
body: JSON.stringify({
|
|
858
|
+
instruction: text,
|
|
859
|
+
intervention_id: interventionId,
|
|
860
|
+
}),
|
|
861
|
+
},
|
|
862
|
+
);
|
|
863
|
+
if (!response.ok) {
|
|
864
|
+
let errText = '';
|
|
865
|
+
try { errText = (await response.text()).slice(0, 400); } catch {}
|
|
866
|
+
return {
|
|
867
|
+
status: 'error',
|
|
868
|
+
httpStatus: response.status,
|
|
869
|
+
error: errText || `HTTP ${response.status}`,
|
|
870
|
+
interventionId,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const body = await response.json().catch(() => ({}));
|
|
874
|
+
const backendStatus = body.status || 'accepted';
|
|
875
|
+
const status = body.duplicate ? 'duplicate' : backendStatus;
|
|
876
|
+
return {
|
|
877
|
+
status,
|
|
878
|
+
interventionId: body.intervention_id || interventionId,
|
|
879
|
+
};
|
|
880
|
+
} catch (e) {
|
|
881
|
+
return {
|
|
882
|
+
status: 'error',
|
|
883
|
+
error: (e && e.message) || String(e),
|
|
884
|
+
interventionId,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async _waitIfPaused() {
|
|
890
|
+
while (this._paused && !this._cancelled) {
|
|
891
|
+
await new Promise(resolve => this._pauseWaiters.add(resolve));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
_releasePauseWaiters() {
|
|
896
|
+
const waiters = [...this._pauseWaiters];
|
|
897
|
+
this._pauseWaiters.clear();
|
|
898
|
+
for (const resolve of waiters) {
|
|
899
|
+
try { resolve(); } catch { /* ignore */ }
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Summarize a prior transcript for resume continuity.
|
|
905
|
+
*
|
|
906
|
+
* @param {Array<{role:string,content:string}>} messages
|
|
907
|
+
* @param {Object} [opts]
|
|
908
|
+
* @returns {Promise<{summary:string,source:string,model:string}>}
|
|
909
|
+
*/
|
|
910
|
+
async summarizeSession(messages, opts = {}) {
|
|
911
|
+
const response = await fetch(`${this.baseUrl}/api/summarize/session`, {
|
|
912
|
+
method: 'POST',
|
|
913
|
+
headers: this._headers({
|
|
914
|
+
'Content-Type': 'application/json',
|
|
915
|
+
'Accept': 'application/json',
|
|
916
|
+
}),
|
|
917
|
+
body: JSON.stringify({
|
|
918
|
+
messages,
|
|
919
|
+
session_id: opts.sessionId || null,
|
|
920
|
+
project_path: opts.projectPath || null,
|
|
921
|
+
max_tokens: opts.maxTokens || 800,
|
|
922
|
+
}),
|
|
923
|
+
signal: AbortSignal.timeout(opts.timeoutMs || 15000),
|
|
924
|
+
});
|
|
925
|
+
if (!response.ok) {
|
|
926
|
+
const text = await response.text().catch(() => '');
|
|
927
|
+
throw new Error(`summary request failed (${response.status})${text ? `: ${text.slice(0, 200)}` : ''}`);
|
|
928
|
+
}
|
|
929
|
+
return await response.json();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function fileDiffEventsForToolResult(toolEvent) {
|
|
934
|
+
const data = toolEvent?.data || {};
|
|
935
|
+
const diffs = Array.isArray(data.file_diffs)
|
|
936
|
+
? data.file_diffs
|
|
937
|
+
: data.file_diff ? [data.file_diff] : [];
|
|
938
|
+
if (!diffs.length) return [];
|
|
939
|
+
|
|
940
|
+
return diffs.map((diff, index) => ({
|
|
941
|
+
type: EVENT_TYPES.FILE_DIFF,
|
|
942
|
+
data: {
|
|
943
|
+
call_id: data.call_id || data._callId || null,
|
|
944
|
+
tool: data.tool || data._tool || '',
|
|
945
|
+
index,
|
|
946
|
+
count: diffs.length,
|
|
947
|
+
path: diff.path || '',
|
|
948
|
+
relative_path: diff.relative_path || '',
|
|
949
|
+
lines_added: diff.lines_added || 0,
|
|
950
|
+
lines_removed: diff.lines_removed || 0,
|
|
951
|
+
truncated: !!diff.truncated,
|
|
952
|
+
truncated_line_count: diff.truncated_line_count || 0,
|
|
953
|
+
hunks: diff.hunks || [],
|
|
954
|
+
unified: diff.unified || '',
|
|
955
|
+
},
|
|
956
|
+
}));
|
|
957
|
+
}
|