@hunterzhu/pulse-cli 0.5.1 → 0.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/dist/bin.js CHANGED
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import { isAbsolute, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { assertJarvisApiUrl, createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
6
+ import { createLocalHost, automationDoctor, requestAutomationPermissions, assertJarvisApiUrl, createBuiltinSkillCapabilityPack, createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
7
7
  import { modelSelectionId, normalizeModelConfig, resolveModelReference } from '@hunterzhu/pulse-server';
8
8
  import { defaultPulseConfig, defaultPulseConfigPath, ensurePulseUserConfig, expandHome, loadPulseConfig } from './config.js';
9
9
  import { runInteractive } from './commands/interactive.js';
@@ -33,13 +33,14 @@ Usage:
33
33
  pulse run <task> [options] run one task
34
34
  pulse sessions [options] list saved conversations
35
35
  pulse resume <conversation-id> [task] continue or recover a conversation
36
- pulse doctor [options] check local configuration
36
+ pulse drafts <conversation-id> <run-id> <draft-id> [--revision <hash> --confirm-owner]\n preview or explicitly migrate an ownerless draft\n pulse doctor [options] check local configuration
37
37
  pulse h5 [start|qr|devices|revoke|install|status|stop|uninstall]
38
38
  mobile web assistant and launchd daemon
39
39
  pulse schedule <add|list|pause|resume|remove|daemon> [options]
40
40
  manage recurring tasks (daemon requires read-only or auto approval)
41
41
  pulse service <install|status|start|stop|uninstall>
42
42
  pulse template <list|show|run> [name] [values...]
43
+ pulse automation doctor check browser and desktop dependencies
43
44
  pulse mcp doctor [server-id]
44
45
 
45
46
  Options:
@@ -51,8 +52,9 @@ Options:
51
52
  --max-output-tokens <n> maximum generated tokens (default 4096)
52
53
  --reasoning-effort <x> low, medium, or high
53
54
  --max-turns <n> maximum ReAct model/tool turns (default 32)
55
+ --max-model-calls <n> maximum model attempts per work task (default 256)
54
56
  --auto-compact-percent <n> compact automatically at this percent of the context window (1-90, default 90)
55
- --format <text|jsonl> output format
57
+ --accept-build-migration <old:new> authorize one reviewed recovery build pair\n --requirements-doc <path> explicitly attach a workspace requirements document\n --format <text|jsonl> output format
56
58
  --resume enter the latest saved session
57
59
  --read-only disable write and shell tools
58
60
  --auto-approve approve local writes and shell execution on your behalf
@@ -96,6 +98,7 @@ const knownBooleanFlags = new Set([
96
98
  'help',
97
99
  'version',
98
100
  'force',
101
+ 'confirm-owner',
99
102
  'jarvis',
100
103
  'no-jarvis',
101
104
  'jarvis-allow-remote',
@@ -142,7 +145,7 @@ export function parse(argv) {
142
145
  options[key] = true;
143
146
  continue;
144
147
  }
145
- if (!command && ['run', 'sessions', 'resume', 'doctor', 'setup', 'schedule', 'service', 'template', 'mcp', 'h5'].includes(arg)) {
148
+ if (!command && ['run', 'sessions', 'resume', 'doctor', 'setup', 'schedule', 'service', 'template', 'mcp', 'h5', 'automation', 'drafts'].includes(arg)) {
146
149
  command = arg;
147
150
  }
148
151
  else {
@@ -210,6 +213,7 @@ export async function hostOptions(parsed) {
210
213
  const maxOutputTokens = option(parsed.options, 'max-output-tokens') ?? process.env.PULSE_MAX_OUTPUT_TOKENS;
211
214
  const reasoningEffort = option(parsed.options, 'reasoning-effort') ?? process.env.PULSE_REASONING_EFFORT;
212
215
  const maxTurns = option(parsed.options, 'max-turns') ?? process.env.PULSE_MAX_TURNS;
216
+ const maxModelCalls = option(parsed.options, 'max-model-calls') ?? process.env.PULSE_MAX_MODEL_CALLS;
213
217
  const maxRuntimeMs = option(parsed.options, 'max-runtime-ms') ?? process.env.PULSE_MAX_RUNTIME_MS;
214
218
  const autoCompactPercent = option(parsed.options, 'auto-compact-percent') ?? process.env.PULSE_AUTO_COMPACT_PERCENT;
215
219
  const apiKeyEnv = profile.apiKeyEnv ?? (profile.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
@@ -225,6 +229,7 @@ export async function hostOptions(parsed) {
225
229
  const configuredReasoningEffort = reasoningEffort === 'low' || reasoningEffort === 'medium' || reasoningEffort === 'high' ? reasoningEffort : modelSelection.reasoningEffort ?? profile.reasoningEffort;
226
230
  const configuredToolChoice = profile.toolChoice;
227
231
  const configuredMaxTurns = parsePositiveInteger(maxTurns) ?? config.maxTurns;
232
+ const configuredMaxModelCalls = parsePositiveInteger(maxModelCalls) ?? config.maxModelCalls ?? 256;
228
233
  const configuredMaxRuntimeMs = parsePositiveInteger(maxRuntimeMs) ?? config.maxRuntimeMs;
229
234
  const configuredAutoCompactPercent = parsePositiveInteger(autoCompactPercent) ?? config.autoCompactPercent;
230
235
  const provider = {
@@ -253,13 +258,26 @@ export async function hostOptions(parsed) {
253
258
  ...(item.toolChoice === undefined ? {} : { toolChoice: item.toolChoice }),
254
259
  }]];
255
260
  }));
261
+ for (const selection of selections)
262
+ if (selection.model.imageInput !== undefined && !['supported', 'unsupported', 'unknown'].includes(selection.model.imageInput))
263
+ throw new Error(`INVALID_MODEL_IMAGE_INPUT:${selection.id}`);
256
264
  const providerModels = Object.fromEntries(selections.map(({ id, providerId, model }) => [id, {
257
265
  provider: providerId,
258
266
  model: model.modelCode,
267
+ ...(model.imageInput === undefined ? {} : { imageInput: model.imageInput }),
259
268
  ...(model.maxContextTokens === undefined ? {} : { maxContextTokens: model.maxContextTokens }),
260
269
  ...(model.maxOutputTokens === undefined ? {} : { maxOutputTokens: model.maxOutputTokens }),
261
270
  ...(model.reasoningEffort === undefined ? {} : { reasoningEffort: model.reasoningEffort }),
262
271
  }]));
272
+ // Explicit CLI/env limits must reach the routed candidate too. Otherwise the
273
+ // model catalog silently overrides the adapter's smaller output/effort limits.
274
+ const activeCandidate = providerModels[activeModelName];
275
+ if (parsePositiveInteger(contextTokens) !== undefined)
276
+ activeCandidate.maxContextTokens = configuredContextTokens;
277
+ if (parsePositiveInteger(maxOutputTokens) !== undefined)
278
+ activeCandidate.maxOutputTokens = configuredMaxOutputTokens;
279
+ if (reasoningEffort === 'low' || reasoningEffort === 'medium' || reasoningEffort === 'high')
280
+ activeCandidate.reasoningEffort = reasoningEffort;
263
281
  const assistantModelName = config.assistantModel ? resolveModelReference(catalog, config.assistantModel) : activeModelName;
264
282
  if (!providerModels[assistantModelName])
265
283
  throw new Error(`UNKNOWN_ASSISTANT_MODEL:${assistantModelName}`);
@@ -335,14 +353,18 @@ export async function hostOptions(parsed) {
335
353
  const skillRoots = capabilitySettings.trustedSkillRoots ?? [];
336
354
  if (!Array.isArray(skillRoots) || skillRoots.some((root) => typeof root !== 'string' || !isAbsolute(expandHome(root) ?? root)))
337
355
  throw new Error('SKILL_TRUSTED_ROOT_MUST_BE_ABSOLUTE');
338
- const enabledCapabilityPacks = capabilitySettings.enabled ?? [];
339
- if (!Array.isArray(enabledCapabilityPacks) || enabledCapabilityPacks.some((id) => typeof id !== 'string') || new Set(enabledCapabilityPacks).size !== enabledCapabilityPacks.length)
356
+ const configuredCapabilityPacks = capabilitySettings.enabled ?? [];
357
+ if (!Array.isArray(configuredCapabilityPacks) || configuredCapabilityPacks.some((id) => typeof id !== 'string') || new Set(configuredCapabilityPacks).size !== configuredCapabilityPacks.length)
340
358
  throw new Error('INVALID_ENABLED_CAPABILITY_LIST');
359
+ // Built-in skills are always enabled so they are discoverable with no external skills directory.
360
+ const enabledCapabilityPacks = ['builtin-skills', ...configuredCapabilityPacks.filter((id) => id !== 'builtin-skills')];
341
361
  const skillNames = capabilitySettings.skills ?? [];
342
362
  if (!Array.isArray(skillNames) || skillNames.some((name) => typeof name !== 'string'))
343
363
  throw new Error('INVALID_SKILL_SELECTION');
344
- const capabilityPacks = [createPdfCapabilityPack(), createSpreadsheetCapabilityPack(), createSkillCapabilityPack({ trustedRoots: skillRoots.map((root) => resolve(expandHome(root) ?? root)) })];
364
+ const capabilityPacks = [createBuiltinSkillCapabilityPack(), createPdfCapabilityPack(), createSpreadsheetCapabilityPack(), createSkillCapabilityPack({ trustedRoots: skillRoots.map((root) => resolve(expandHome(root) ?? root)) })];
345
365
  for (const [id, server] of Object.entries(capabilitySettings.mcpServers ?? {})) {
366
+ if ((id === 'browser' && capabilitySettings.browser) || (id === 'computer' && capabilitySettings.computer))
367
+ throw new Error(`AUTOMATION_CAPABILITY_CONFLICT:${id}`);
346
368
  if (!server || typeof server.command !== 'string' || server.command.trim().length === 0 || (server.args !== undefined && (!Array.isArray(server.args) || server.args.some((arg) => typeof arg !== 'string'))))
347
369
  throw new Error(`INVALID_MCP_SERVER_CONFIG:${id}`);
348
370
  if (server.toolPolicies && Object.entries(server.toolPolicies).some(([name, policy]) => !name || !['read', 'write', 'external'].includes(policy)))
@@ -366,7 +388,7 @@ export async function hostOptions(parsed) {
366
388
  }));
367
389
  }
368
390
  for (const id of enabledCapabilityPacks) {
369
- if (!capabilityPacks.some((pack) => pack.manifest.id === id))
391
+ if (!['browser', 'computer'].includes(id) && !capabilityPacks.some((pack) => pack.manifest.id === id))
370
392
  throw new Error(`UNKNOWN_ENABLED_CAPABILITY:${id}`);
371
393
  }
372
394
  const rawSystemPrompt = option(parsed.options, 'system-prompt') ?? process.env.PULSE_SYSTEM_PROMPT ?? config.systemPrompt;
@@ -423,6 +445,7 @@ export async function hostOptions(parsed) {
423
445
  provider,
424
446
  providerProfiles,
425
447
  providerModels,
448
+ automation: { ...(capabilitySettings.browser ? { browser: capabilitySettings.browser } : {}), ...(capabilitySettings.computer ? { computer: capabilitySettings.computer } : {}) },
426
449
  modelPricing: Object.fromEntries(selections.flatMap(({ id, model }) => model.pricing ? [[id, model.pricing]] : [])),
427
450
  ...(taskRouting === undefined ? {} : { taskRouting }),
428
451
  capabilityPacks,
@@ -439,6 +462,12 @@ export async function hostOptions(parsed) {
439
462
  ...(mockParallelPlan === undefined ? {} : { mockParallelPlan }),
440
463
  ...(approvalMode === undefined ? {} : { approvalMode }),
441
464
  ...(configuredMaxTurns === undefined ? {} : { maxTurns: configuredMaxTurns }),
465
+ maxModelCalls: configuredMaxModelCalls,
466
+ ...(option(parsed.options, 'accept-build-migration') ? { recoveryBuildMigration: (() => { const pair = option(parsed.options, 'accept-build-migration').split(':'); if (pair.length !== 2 || pair.some(hash => !/^[a-f0-9]{64}$/.test(hash)))
467
+ throw new Error('Invalid build migration pair; use the two hashes in the recovery diagnostic.'); return { from: pair[0], to: pair[1] }; })() } : {}),
468
+ ...(config.modelBudgetPolicy === undefined ? {} : { modelBudgetPolicy: config.modelBudgetPolicy }),
469
+ ...(config.persistenceBackend === undefined ? {} : { persistenceBackend: config.persistenceBackend }),
470
+ ...(config.maxCostByCurrency === undefined ? {} : { maxCostByCurrency: config.maxCostByCurrency }),
442
471
  ...(configuredMaxRuntimeMs === undefined ? {} : { maxRuntimeMs: configuredMaxRuntimeMs }),
443
472
  ...(configuredAutoCompactPercent === undefined ? {} : { autoCompactPercent: Math.min(90, configuredAutoCompactPercent) }),
444
473
  ...(allowNetwork === undefined ? {} : { allowNetwork }),
@@ -457,7 +486,7 @@ export async function hostOptions(parsed) {
457
486
  const source = entry && effectiveProviders[entry.providerId];
458
487
  if (!entry || !source || source.provider !== 'ollama')
459
488
  throw new Error('APPROVAL_REVIEW_REQUIRES_OLLAMA');
460
- return { provider: 'ollama', model: entry.model.modelCode, baseURL: source.baseURL ?? 'http://127.0.0.1:11434', timeoutMs: Math.max(1_000, Math.min(60_000, config.approvalReview.timeoutMs ?? 15_000)) };
489
+ return { provider: 'ollama', model: entry.model.modelCode, baseURL: source.baseURL ?? 'http://127.0.0.1:11434', timeoutMs: Math.max(30_000, Math.min(60_000, config.approvalReview.timeoutMs ?? 30_000)) };
461
490
  })() } : {}),
462
491
  };
463
492
  }
@@ -477,9 +506,43 @@ export async function main() {
477
506
  return runSetup(parsed.options.force === true, expandHome(option(parsed.options, 'config')));
478
507
  }
479
508
  const options = await hostOptions(parsed);
509
+ if (parsed.command === 'automation') {
510
+ if (parsed.positionals[0] === 'permissions') {
511
+ process.stdout.write(JSON.stringify(await requestAutomationPermissions(options.automation), null, 2) + '\n');
512
+ return 0;
513
+ }
514
+ if (parsed.positionals[0] !== 'doctor')
515
+ throw new Error('Usage: pulse automation doctor|permissions');
516
+ if (parsed.options.live === true) {
517
+ const host = createLocalHost(options);
518
+ try {
519
+ process.stdout.write(JSON.stringify(await host.probeVisionModel(option(parsed.options, 'model') ?? options.taskRouting?.vision?.[0] ?? options.activeModel ?? ''), null, 2) + '\n');
520
+ return 0;
521
+ }
522
+ finally {
523
+ await host.close();
524
+ }
525
+ }
526
+ const report = await automationDoctor(options.automation);
527
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
528
+ return report.desktop.error && options.enabledCapabilityPacks?.includes('computer') ? 1 : 0;
529
+ }
480
530
  if (parsed.command === 'doctor') {
481
531
  return runDoctor(options, parsed.options.live === true, option(parsed.options, 'format') ?? 'text');
482
532
  }
533
+ if (parsed.command === 'drafts') {
534
+ const [conversationId, runId, draftId] = parsed.positionals;
535
+ if (!conversationId || !runId || !draftId)
536
+ throw new Error('Usage: pulse drafts <conversation-id> <run-id> <draft-id> [--revision <hash> --confirm-owner]');
537
+ const host = createLocalHost(options);
538
+ try {
539
+ process.stdout.write(JSON.stringify(await host.migrateDraft(conversationId, runId, draftId, option(parsed.options, 'revision'), parsed.options['confirm-owner'] === true), null, 2) + '\n');
540
+ return 0;
541
+ }
542
+ finally {
543
+ await host.close();
544
+ }
545
+ }
483
546
  if (parsed.command === 'sessions') {
484
547
  return runSessions(options, option(parsed.options, 'format') ?? 'text', version);
485
548
  }
@@ -536,7 +599,7 @@ export async function main() {
536
599
  }
537
600
  if (!task)
538
601
  throw new Error('TASK_REQUIRED');
539
- return runOneShot(options, task, option(parsed.options, 'format') ?? 'text', version);
602
+ return runOneShot(options, task, option(parsed.options, 'format') ?? 'text', version, option(parsed.options, 'requirements-doc') ? [option(parsed.options, 'requirements-doc')] : undefined);
540
603
  }
541
604
  if (parsed.command === 'resume') {
542
605
  const id = parsed.positionals.shift();
@@ -1,3 +1,3 @@
1
1
  import type { LocalHostOptions } from '@hunterzhu/pulse-server';
2
- export declare function findResumeConversation(options: LocalHostOptions): Promise<string | undefined>;
2
+ export declare function findResumeConversation(options: LocalHostOptions, conversationId?: string | undefined): Promise<string | undefined>;
3
3
  export declare function runInteractive(options: LocalHostOptions, conversationId?: string | undefined, initialTask?: string | undefined, version?: string, resumeLatest?: boolean): Promise<number>;
@@ -4,10 +4,24 @@ import { App } from '../components/App.js';
4
4
  import { createLocalHost } from '@hunterzhu/pulse-server';
5
5
  import { closePendingHosts } from '../hooks/useHost.js';
6
6
  import { runOneShot } from './run.js';
7
- export async function findResumeConversation(options) {
7
+ export async function findResumeConversation(options, conversationId) {
8
8
  const host = createLocalHost(options);
9
9
  await host.init();
10
10
  try {
11
+ if (conversationId !== undefined) {
12
+ // Validate an explicit ID without creating a conversation. A missing ID
13
+ // must fail loudly instead of silently opening a fresh history.
14
+ try {
15
+ await host.getConversation(conversationId);
16
+ }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT') {
19
+ throw Object.assign(new Error(`未找到指定会话:${conversationId}`), { code: 'CONVERSATION_NOT_FOUND' });
20
+ }
21
+ throw error;
22
+ }
23
+ return conversationId;
24
+ }
11
25
  const sessions = await host.listConversations();
12
26
  return sessions.find((session) => session.activeRunId)?.id ?? sessions[0]?.id;
13
27
  }
@@ -23,10 +37,11 @@ export async function runInteractive(options, conversationId, initialTask, versi
23
37
  process.stderr.write('交互模式需要 TTY 终端。非交互执行请使用 "pulse run <task>"。\n');
24
38
  return 1;
25
39
  }
26
- const selectedConversationId = resumeLatest
27
- ? await findResumeConversation(options)
28
- : conversationId;
29
- const { waitUntilExit, unmount } = render(_jsx(App, { hostOptions: options, conversationId: selectedConversationId, initialTask: initialTask, resumeOnStart: resumeLatest && selectedConversationId !== undefined, version: version }), {
40
+ const shouldResume = resumeLatest || conversationId !== undefined;
41
+ const selectedConversationId = shouldResume
42
+ ? await findResumeConversation(options, conversationId)
43
+ : undefined;
44
+ const { waitUntilExit, unmount } = render(_jsx(App, { hostOptions: options, conversationId: selectedConversationId, initialTask: initialTask, resumeOnStart: shouldResume && selectedConversationId !== undefined, version: version }), {
30
45
  // Keep Pulse in its own full-screen buffer so the shell's previous
31
46
  // command history does not become part of the chat workspace.
32
47
  alternateScreen: true,
@@ -53,6 +53,7 @@ export async function runResume(options, conversationId, task, format = 'text',
53
53
  }
54
54
  const outcome = await run.outcome();
55
55
  const taskOutcome = await run.taskOutcome();
56
+ const usage = await run.usage();
56
57
  if (format === 'jsonl') {
57
58
  process.stdout.write(`${JSON.stringify({
58
59
  schemaVersion: 1,
@@ -60,6 +61,7 @@ export async function runResume(options, conversationId, task, format = 'text',
60
61
  runId: run.id,
61
62
  status: outcome.status,
62
63
  taskOutcome: taskOutcome ?? null,
64
+ usage,
63
65
  text: outcome.text ?? null,
64
66
  error: outcome.error ?? null,
65
67
  })}\n`);
@@ -1,2 +1,2 @@
1
1
  import { type LocalHostOptions } from '@hunterzhu/pulse-server';
2
- export declare function runOneShot(options: LocalHostOptions, task: string, format?: string, version?: string): Promise<number>;
2
+ export declare function runOneShot(options: LocalHostOptions, task: string, format?: string, version?: string, requirementPaths?: string[]): Promise<number>;
@@ -23,7 +23,7 @@ function writeEvent(event, format, printed) {
23
23
  if (toolLines.length)
24
24
  process.stderr.write(`${toolLines.join('\n')}\n`);
25
25
  }
26
- export async function runOneShot(options, task, format = 'text', version = '0.4.1') {
26
+ export async function runOneShot(options, task, format = 'text', version = '0.4.1', requirementPaths) {
27
27
  const host = createLocalHost(options);
28
28
  let activeRun;
29
29
  const unbindSignals = bindRunSignals(host, () => activeRun);
@@ -32,6 +32,7 @@ export async function runOneShot(options, task, format = 'text', version = '0.4.
32
32
  const conversation = await host.createConversation(options.cwd ? { cwd: options.cwd } : {});
33
33
  const run = await host.sendMessage(conversation.id, {
34
34
  text: task,
35
+ ...(requirementPaths ? { requirementPaths } : {}),
35
36
  format: format === 'jsonl' ? 'jsonl' : 'text',
36
37
  });
37
38
  activeRun = run;
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { LocalHostOptions } from '@hunterzhu/pulse-server';
2
+ import { type LocalHostOptions } from '@hunterzhu/pulse-server';
3
3
  export interface AppProps {
4
4
  hostOptions: LocalHostOptions;
5
5
  conversationId?: string | undefined;
@@ -1,6 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback, useRef } from 'react';
3
3
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
4
+ import { automationDoctor } from '@hunterzhu/pulse-server';
4
5
  import { Header } from './Header.js';
5
6
  import { Welcome } from './Welcome.js';
6
7
  import { MessageList } from './MessageList.js';
@@ -136,6 +137,34 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
136
137
  })();
137
138
  }, [hostReady, conversation, initialTask, isRunning, addUserMessage, addAssistantMessage, createConversation, sendMessage]);
138
139
  const { commands, executeCommand, isSlashCommand } = useSlashCommands({
140
+ onAutomation: async (args) => {
141
+ if (!host)
142
+ return;
143
+ const report = (text) => addAssistantMessage({ id: `automation-${Date.now()}`, role: 'system', text, createdAt: new Date().toISOString() });
144
+ try {
145
+ const [action = 'status', id] = args.split(/\s+/).filter(Boolean);
146
+ if (action === 'doctor') {
147
+ await report(JSON.stringify(await automationDoctor(hostOptions.automation), null, 2));
148
+ return;
149
+ }
150
+ const sessions = host.automation.list();
151
+ if (action === 'status') {
152
+ await report(JSON.stringify(sessions.map(session => ({ ...session, screenshotPath: session.state === 'closed' ? undefined : host.automation.screenshotPath(session.id) })), null, 2));
153
+ return;
154
+ }
155
+ if (host.getAutomationOptions().approvalMode === 'read-only')
156
+ throw new Error('READ_ONLY');
157
+ const selected = id ?? sessions.find(session => session.state !== 'closed')?.id;
158
+ if (!selected || !['pause', 'takeover', 'resume', 'stop'].includes(action))
159
+ throw new Error('用法:/automation status|doctor|pause|takeover|resume|stop [id]');
160
+ const result = await host.automation.control(selected, action === 'stop' ? 'close' : action, 'local-cli');
161
+ const { lease: _lease, ...status } = result;
162
+ await report(JSON.stringify(status, null, 2) + (action === 'takeover' ? '\nAI 已暂停,可在本机手动操作;完成后 /automation resume。' : ''));
163
+ }
164
+ catch (error) {
165
+ await report(String(error));
166
+ }
167
+ },
139
168
  onMouse: (arg) => setMouseEnabled((current) => arg === 'off' ? false : arg === 'on' ? true : !current),
140
169
  onCopy: async () => {
141
170
  if (selectedText) {
@@ -574,6 +603,6 @@ export function App({ hostOptions, conversationId: initialConversationId, initia
574
603
  }, onInput: () => {
575
604
  setApprovalChoiceFocused(false);
576
605
  setApprovalInputMode(true);
577
- } })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value) }, askRequest.effectId)) : (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [skillError && _jsxs(Text, { color: "red", children: ["\u6280\u80FD\u7D22\u5F15\u5931\u8D25\uFF1A", skillError] }), _jsx(InputArea, { suggestions: [...commands, ...skillSuggestions(skillNames, commands.flatMap(command => [command, ...(command.aliases ?? []).map(name => ({ name, description: command.description }))]))], onSearchSkills: refreshSkills, onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' })] }))] }));
606
+ } })) : askRequest ? (_jsx(AskPrompt, { request: askRequest, disabled: approvalSubmitting, onReply: (value) => void replyAsk(askRequest.effectId, value), onCancel: () => void cancelRun() }, askRequest.effectId)) : (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [skillError && _jsxs(Text, { color: "red", children: ["\u6280\u80FD\u7D22\u5F15\u5931\u8D25\uFF1A", skillError] }), _jsx(InputArea, { suggestions: [...commands, ...skillSuggestions(skillNames, commands.flatMap(command => [command, ...(command.aliases ?? []).map(name => ({ name, description: command.description }))]))], onSearchSkills: refreshSkills, onSubmit: (txt) => void handleSubmit(txt), onRememberInput: rememberUserInput, onNavigateHistory: navigateUserInputHistory, disabled: approvalSubmitting, focus: !approvalSubmitting, placeholder: isRunning ? '运行中也可以输入;/cancel 可取消当前运行...' : '输入消息或 /help...' })] }))] }));
578
607
  }
579
608
  export default App;
@@ -3,6 +3,7 @@ interface Props {
3
3
  request: AskRequest;
4
4
  disabled?: boolean;
5
5
  onReply: (value: Record<string, unknown>) => void;
6
+ onCancel?: () => void;
6
7
  }
7
- export declare function AskPrompt({ request, disabled, onReply }: Props): import("react").JSX.Element;
8
+ export declare function AskPrompt({ request, disabled, onReply, onCancel }: Props): import("react").JSX.Element;
8
9
  export {};
@@ -1,15 +1,28 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import TextInput from './TextInput.js';
4
- import { useState } from 'react';
4
+ import { useRef, useState } from 'react';
5
5
  import { theme } from '../theme.js';
6
- export function AskPrompt({ request, disabled = false, onReply }) {
6
+ /** Typed text that cancels the pending question instead of choosing an option. */
7
+ const CANCEL_COMMAND = /^\/?\s*(?:cancel|quit|exit)\s*$/i;
8
+ export function AskPrompt({ request, disabled = false, onReply, onCancel }) {
7
9
  const [value, setValue] = useState(request.defaultValue ?? '');
8
10
  const [inputLines, setInputLines] = useState([]);
9
11
  const [index, setIndex] = useState(0);
10
12
  const [selected, setSelected] = useState([]);
13
+ const [typed, setTyped] = useState('');
14
+ // Mirror of `typed` that updates synchronously, so an Enter arriving in the
15
+ // same batch as a multi-character paste still sees the buffered text.
16
+ const typedRef = useRef('');
17
+ const [hint, setHint] = useState(null);
18
+ const updateTyped = (next) => {
19
+ const value = next(typedRef.current);
20
+ typedRef.current = value;
21
+ setTyped(value);
22
+ };
11
23
  const options = request.options ?? [];
12
24
  const clampedIndex = options.length === 0 ? 0 : Math.min(Math.max(0, index), options.length - 1);
25
+ const isList = request.type === 'choice' || request.type === 'multi';
13
26
  useInput((input, key) => {
14
27
  if (disabled)
15
28
  return;
@@ -23,8 +36,11 @@ export function AskPrompt({ request, disabled = false, onReply }) {
23
36
  }
24
37
  return;
25
38
  }
26
- if ((request.type !== 'choice' && request.type !== 'multi') || options.length === 0)
39
+ if (!isList || options.length === 0)
27
40
  return;
41
+ // Escape already cancels the whole run at the app level; here we add an
42
+ // explicit, text-level cancel so Enter never confirms the default option
43
+ // by accident after the user typed /cancel.
28
44
  if (key.upArrow) {
29
45
  setIndex((current) => Math.max(0, current - 1));
30
46
  return;
@@ -33,25 +49,51 @@ export function AskPrompt({ request, disabled = false, onReply }) {
33
49
  setIndex((current) => Math.min(options.length - 1, current + 1));
34
50
  return;
35
51
  }
36
- if (request.type === 'choice' && key.return) {
37
- const option = options[Math.min(Math.max(0, index), options.length - 1)];
38
- if (option)
39
- onReply({ value: option.value });
52
+ if (key.backspace || key.delete) {
53
+ updateTyped((current) => current.slice(0, -1));
54
+ setHint(null);
40
55
  return;
41
56
  }
42
- if (request.type !== 'multi')
43
- return;
44
- if (input === ' ') {
45
- const option = options[Math.min(Math.max(0, index), options.length - 1)];
57
+ if (request.type === 'multi' && input === ' ') {
58
+ const option = options[clampedIndex];
46
59
  if (!option)
47
60
  return;
48
61
  setSelected((current) => current.includes(option.value) ? current.filter((item) => item !== option.value) : [...current, option.value]);
62
+ return;
49
63
  }
50
- else if (key.return) {
64
+ if (key.return) {
65
+ const pending = typedRef.current.trim();
66
+ if (pending) {
67
+ if (CANCEL_COMMAND.test(pending)) {
68
+ onCancel?.();
69
+ return;
70
+ }
71
+ // A pending line of text must never turn Enter into a silent default
72
+ // selection: clear it and ask the user to choose deliberately.
73
+ updateTyped(() => '');
74
+ setHint('未提交选择:请用 ↑↓ 移动后按 Enter 确认;如需取消请输入 /cancel 或按 Esc。');
75
+ return;
76
+ }
77
+ if (request.type === 'choice') {
78
+ const option = options[clampedIndex];
79
+ if (option)
80
+ onReply({ value: option.value });
81
+ return;
82
+ }
51
83
  if (selected.length < (request.min ?? 0) || (request.max !== undefined && selected.length > request.max))
52
84
  return;
53
85
  onReply({ values: selected });
86
+ return;
87
+ }
88
+ // Buffer the whole printable segment so pasted text such as /cancel is
89
+ // recognised without a dedicated box.
90
+ if (!key.ctrl && !key.meta) {
91
+ const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
92
+ if (printable) {
93
+ updateTyped((current) => Array.from(current + printable).slice(-32).join(''));
94
+ setHint(null);
95
+ }
54
96
  }
55
97
  });
56
- return (_jsxs(Box, { borderStyle: "double", borderColor: theme.primary, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: theme.primary, bold: true, children: ["\u2726 \u9700\u8981\u4F60\u7684\u56DE\u7B54 \u00B7 ", request.toolName] }), _jsx(Text, { color: "white", children: request.prompt }), request.type === 'choice' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', option.label] }, `${optionIndex}:${option.value}`))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 \u79FB\u52A8\uFF0CEnter \u786E\u8BA4" })] })), request.type === 'multi' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', selected.includes(option.value) ? '[x] ' : '[ ] ', option.label] }, `${optionIndex}:${option.value}`))), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 \u79FB\u52A8\uFF0C\u7A7A\u683C\u9009\u62E9\uFF0CEnter \u786E\u8BA4", request.min ? `(至少 ${request.min} 项)` : ''] })] })), request.type === 'input' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [inputLines.map((line, lineIndex) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(Text, { children: line })] }, `${lineIndex}:${line}`))), _jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(TextInput, { focus: !disabled, value: value, onChange: setValue, placeholder: request.placeholder ?? '' })] })] }))] }));
98
+ return (_jsxs(Box, { borderStyle: "double", borderColor: theme.primary, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: theme.primary, bold: true, children: ["\u2726 \u9700\u8981\u4F60\u7684\u56DE\u7B54 \u00B7 ", request.toolName] }), _jsx(Text, { color: "white", children: request.prompt }), hint ? _jsx(Text, { color: "yellow", children: hint }) : null, isList && typed ? _jsxs(Text, { color: "yellow", children: ["\u5DF2\u8F93\u5165\uFF1A", typed] }) : null, request.type === 'choice' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', option.label] }, `${optionIndex}:${option.value}`))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 \u79FB\u52A8\uFF0CEnter \u786E\u8BA4\uFF1BEsc \u6216\u8F93\u5165 /cancel \u53D6\u6D88" })] })), request.type === 'multi' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [options.map((option, optionIndex) => (_jsxs(Text, { ...(optionIndex === clampedIndex ? { color: theme.primary } : {}), children: [optionIndex === clampedIndex ? '› ' : ' ', selected.includes(option.value) ? '[x] ' : '[ ] ', option.label] }, `${optionIndex}:${option.value}`))), _jsxs(Text, { dimColor: true, children: ["\u2191\u2193 \u79FB\u52A8\uFF0C\u7A7A\u683C\u9009\u62E9\uFF0CEnter \u786E\u8BA4", request.min ? `(至少 ${request.min} 项)` : '', "\uFF1BEsc \u6216\u8F93\u5165 /cancel \u53D6\u6D88"] })] })), request.type === 'input' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [inputLines.map((line, lineIndex) => (_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(Text, { children: line })] }, `${lineIndex}:${line}`))), _jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: "\u203A " }), _jsx(TextInput, { focus: !disabled, value: value, onChange: setValue, placeholder: request.placeholder ?? '' })] })] }))] }));
57
99
  }
package/dist/config.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface PulseCliProviderProfile {
6
6
  apiKeyEnv?: string;
7
7
  maxContextTokens?: number;
8
8
  maxOutputTokens?: number;
9
+ imageInput?: 'supported' | 'unsupported' | 'unknown';
9
10
  reasoningEffort?: 'low' | 'medium' | 'high';
10
11
  toolChoice?: 'auto' | 'required' | 'none' | {
11
12
  type: 'function';
@@ -25,6 +26,7 @@ export interface PulseCliModel {
25
26
  displayName?: string;
26
27
  maxContextTokens?: number;
27
28
  maxOutputTokens?: number;
29
+ imageInput?: 'supported' | 'unsupported' | 'unknown';
28
30
  reasoningEffort?: 'low' | 'medium' | 'high';
29
31
  pricing?: {
30
32
  currency: string;
@@ -33,7 +35,7 @@ export interface PulseCliModel {
33
35
  version: string;
34
36
  };
35
37
  }
36
- export type PulseModelTask = 'reason' | 'plan' | 'merge' | 'verify';
38
+ export type PulseModelTask = 'reason' | 'plan' | 'merge' | 'verify' | 'vision';
37
39
  export interface PulseCliMcpServer {
38
40
  command: string;
39
41
  args?: string[];
@@ -47,6 +49,8 @@ export interface PulseCliMcpServer {
47
49
  toolPolicies?: Record<string, 'read' | 'write' | 'external'>;
48
50
  }
49
51
  export interface PulseCliCapabilities {
52
+ browser?: import('@hunterzhu/pulse-server').AutomationConfig['browser'];
53
+ computer?: import('@hunterzhu/pulse-server').AutomationConfig['computer'];
50
54
  /** IDs must be explicitly enabled; workspace config cannot enable host extensions. */
51
55
  enabled?: string[];
52
56
  /** Optional discovery allow-list of skill directory names; omitted discovers all. Bodies load only on invocation. */
@@ -74,7 +78,12 @@ export interface PulseCliConfig {
74
78
  capabilities?: PulseCliCapabilities;
75
79
  approvalMode?: 'read-only' | 'ask' | 'auto';
76
80
  maxTurns?: number;
77
- /** Wall-clock limit for one run. Unset keeps the server default of 15 minutes. */
81
+ /** Provider attempts allowed across one controlled work task; defaults to 256. */
82
+ maxModelCalls?: number;
83
+ modelBudgetPolicy?: import('@hunterzhu/pulse-server').LocalHostOptions['modelBudgetPolicy'];
84
+ persistenceBackend?: 'json' | 'sqlite' | 'external-files';
85
+ maxCostByCurrency?: Record<string, number>;
86
+ /** Optional wall-clock limit for one run. Unset means no overall deadline. */
78
87
  maxRuntimeMs?: number;
79
88
  /** Percent of maxContextTokens that triggers automatic history compaction. Clamped to 1–90. */
80
89
  autoCompactPercent?: number;
package/dist/config.js CHANGED
@@ -22,6 +22,7 @@ export const defaultPulseConfig = {
22
22
  activeModel: 'mock/mock',
23
23
  approvalMode: 'ask',
24
24
  maxTurns: 32,
25
+ maxModelCalls: 256,
25
26
  autoCompactPercent: 90,
26
27
  allowNetwork: false,
27
28
  jarvis: {
@@ -1,5 +1,6 @@
1
1
  import type { SlashCommand } from '../types.js';
2
2
  export interface SlashCommandDependencies {
3
+ onAutomation?: (args: string) => void | Promise<void>;
3
4
  onMouse?: (args: string) => void | Promise<void>;
4
5
  onCopy?: () => void | Promise<void>;
5
6
  onHelp?: () => void | Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useCallback } from 'react';
2
2
  export function useSlashCommands(deps) {
3
3
  const commands = useMemo(() => [
4
+ { name: '/automation', description: '自动化会话 [status|pause|takeover|resume|stop] [id]', execute: async (args) => deps.onAutomation?.(args) },
4
5
  { name: '/mouse', description: '滚轮和触控板滚动 [on|off]', execute: async (args) => deps.onMouse?.(args) },
5
6
  { name: '/copy', description: '复制选区或最近一条完整回复', execute: async () => deps.onCopy?.() },
6
7
  { name: '/help', aliases: ['/h'], description: '显示帮助信息', execute: async () => deps.onHelp?.() },
@@ -1,5 +1,43 @@
1
1
  // Generated from CHANGELOG.md and CHANGELOG.en.md by scripts/release/sync-cli-highlights.mjs. Do not edit manually.
2
2
  export const releaseHighlights = {
3
+ "0.6.0": [
4
+ {
5
+ "zh": "新增受管理的浏览器与桌面自动化,支持页面观察、截图操作、上传下载、暂停及人工接管,并在 CLI 和 H5 中提供入口。",
6
+ "en": "Added managed browser and desktop automation with page observation, screenshot actions, uploads and downloads, pause, and human takeover through CLI and H5 controls."
7
+ },
8
+ {
9
+ "zh": "Web 工作台展示任务执行过程、阶段进度和工具调用详情,便于查看运行状态与审批内容。",
10
+ "en": "The Web workbench displays task execution traces, stage progress, and tool-call details to make run status and approval requests easier to inspect."
11
+ },
12
+ {
13
+ "zh": "改善长任务的上下文压缩、工具输入恢复、循环检测和预算处理,保留中断续跑的状态及调用记录。",
14
+ "en": "Improved context compaction, tool-input recovery, loop detection, and budget handling for long tasks, preserving state and call accounting across interrupted runs."
15
+ },
16
+ {
17
+ "zh": "按明确的并行要求调度独立阶段,修复并行工作单元的状态访问与依赖证据传递。",
18
+ "en": "Schedule independent stages in parallel when explicitly requested, and fix worker state access and dependency evidence propagation."
19
+ },
20
+ {
21
+ "zh": "保存计划后执行与内容绑定的确认,计划变更需重新确认;审批继续保留人工回退和操作范围限制。",
22
+ "en": "Bind execution confirmation to the saved plan and require confirmation again after changes; approvals retain human fallback and operation scope checks."
23
+ },
24
+ {
25
+ "zh": "保护原有测试与验收命令,在修改前阻止破坏性补丁,同时允许授权的新测试补充和修正。",
26
+ "en": "Protect original tests and acceptance commands by rejecting destructive patches before writes, while allowing authorized additions and corrections to new tests."
27
+ },
28
+ {
29
+ "zh": "修复验证证据的新鲜度、无效引用恢复及已通过测试覆盖先前失败子集的判定,减少重复验证与错误阻塞。",
30
+ "en": "Fix evidence freshness, recovery from invalid citations, and recognition of passing test supersets that cover earlier failed subsets, reducing redundant checks and false blocks."
31
+ },
32
+ {
33
+ "zh": "修复 Windows 安装依赖只读隔离、相邻源码的读取边界、脚本审批路径显示及沙箱权限分批初始化,减少无关脚本的递归依赖授权开销,完善跨平台回归验证。",
34
+ "en": "Fix Windows read-only isolation for installed dependencies, read boundaries for adjacent source, script approval path display, and batched sandbox permission initialization; reduce recursive dependency grants for unrelated scripts and improve cross-platform regression checks."
35
+ },
36
+ {
37
+ "zh": "H5 停止服务时等待正在启动的助理运行完成清理,保留后续排队消息;会话文件统一原子写入,修复并发读取异常和请求挂起。",
38
+ "en": "H5 shutdown waits for assistant launches already in progress to settle and preserves later queued messages; atomic conversation writes prevent concurrent read errors and hanging requests."
39
+ }
40
+ ],
3
41
  "0.5.1": [
4
42
  {
5
43
  "zh": "模型目录按供应商管理,支持不同供应商登记同名模型;默认模型、助理聊天、任务路由、即时预回复和审批复核统一选择已登记模型。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "A local-first AI task assistant for coding, research, and file workflows.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "keywords": [
@@ -36,7 +36,7 @@
36
36
  "registry": "https://registry.npmjs.org"
37
37
  },
38
38
  "dependencies": {
39
- "@hunterzhu/pulse-server": "0.5.1",
39
+ "@hunterzhu/pulse-server": "0.6.0",
40
40
  "chalk": "^5.4.1",
41
41
  "highlight.js": "^11.10.0",
42
42
  "ink": "^7.1.1",