dsh-adb 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,8 @@ Topics: `dsh-plugin` `dsh` `adb` `android` `automotive` `bench`
42
42
  | `adb_device_report` | One-click health report: device identity + top-RSS processes + crash buffer (real crashes w/ stacks vs. boot markers) + W/E/F logcat aggregated by tag + storage + health verdict; each section degrades independently; persisted under `reportDir` |
43
43
  | `adb_wait_for` | Wait until a device condition holds — device-online / boot-complete / process appeared / logcat keyword — polling up to a budget, instead of sleeping a fixed number of seconds; returns `matched:false` on timeout |
44
44
  | `adb_operation_ledger` | Append-only device operation ledger (record/list/rollback): record installs/pushes/etc., list history, or roll an app back to its last known-good APK (`adb install -r`); persisted as `operations.json` — the trust base for agent-driven device modification |
45
+ | `adb_screenshot` | Capture the device screen into a local PNG (screencap → pull), returning the saved path, byte size, and pixel dimensions — durable evidence for crash scenes / UI states / test frames |
46
+ | `adb_watch_crash` | Watch the crash buffer for NEW real crashes (foreground poll or background job; boot markers ignored; `matched:false` on timeout) — the first link of the monitor→capture→attribute chain |
45
47
 
46
48
  Errors are structured `AdbError` with stable codes: `ADB_NOT_FOUND`, `ADB_UNAVAILABLE`, `DEVICE_NOT_FOUND`, `NO_DEVICES`, `CONNECT_FAILED`, `INSTALL_FAILED`, `ADB_EXIT_<code>`, etc.
47
49
 
@@ -65,6 +67,7 @@ Set the `config` block in `cordis.patch.yml` (or a profile patch):
65
67
  | `timeoutMs` | Per-command timeout | 30000 |
66
68
  | `baselineDir` | Directory for `adb_perf_baseline` storage | `~/.dsh/storages/dsh-adb` |
67
69
  | `reportDir` | Directory for `adb_device_report` storage | `<baselineDir>/reports` |
70
+ | `screenshotDir` | Directory for `adb_screenshot` storage | `<baselineDir>/screenshots` |
68
71
 
69
72
  ## Development
70
73
 
package/README.zh-CN.md CHANGED
@@ -42,6 +42,8 @@ Topics:`dsh-plugin` `dsh` `adb` `android` `automotive` `bench`
42
42
  | `adb_device_report` | 一键体检:设备信息 + Top RSS 进程 + 崩溃缓冲(真实崩溃带堆栈/启动标记分类)+ W/E/F 日志按 tag 聚合 + 存储用量 + 健康结论;每节独立降级;落盘到 `reportDir` |
43
43
  | `adb_wait_for` | 等待原语:等设备上线 / 启动完成 / 进程出现 / logcat 出现关键字,轮询到预算上限,替代盲目 sleep;超时返回 `matched:false` |
44
44
  | `adb_operation_ledger` | 操作回滚台账(record/list/rollback):追加记录安装/推送等操作,查询历史,或回滚到最近一次成功安装的 APK(`adb install -r`);落盘 `operations.json` —— agent 自主改设备的信任基础 |
45
+ | `adb_screenshot` | 截图落盘 PNG(screencap → pull),返回路径/字节数/像素尺寸 —— 崩溃现场、UI 状态、测试前后对比的持久证据 |
46
+ | `adb_watch_crash` | 崩溃看门狗:监控 crash buffer 的**新**真实崩溃(前台轮询或后台 job;启动标记不算;超时返回 `matched:false`)—— 「盯崩溃→采集→归因」链第一环 |
45
47
 
46
48
  错误码:`ADB_NOT_FOUND`、`ADB_UNAVAILABLE`、`DEVICE_NOT_FOUND`、`NO_DEVICES`、`CONNECT_FAILED`、`INSTALL_FAILED`、`ADB_EXIT_<code>` 等,均为结构化 `AdbError`。
47
49
 
@@ -65,6 +67,7 @@ Topics:`dsh-plugin` `dsh` `adb` `android` `automotive` `bench`
65
67
  | `timeoutMs` | 命令超时 | 30000 |
66
68
  | `baselineDir` | `adb_perf_baseline` 基线存储目录 | `~/.dsh/storages/dsh-adb` |
67
69
  | `reportDir` | `adb_device_report` 体检报告存储目录 | `<baselineDir>/reports` |
70
+ | `screenshotDir` | `adb_screenshot` 截图存储目录 | `<baselineDir>/screenshots` |
68
71
 
69
72
  ## 开发
70
73
 
package/lib/index.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface Config {
15
15
  baselineDir?: string;
16
16
  /** Directory for adb_device_report storage; defaults to <baselineDir>/reports. */
17
17
  reportDir?: string;
18
+ /** Directory for adb_screenshot storage; defaults to <baselineDir>/screenshots. */
19
+ screenshotDir?: string;
18
20
  }
19
21
  export declare const Config: Schema<Config>;
20
22
  export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js CHANGED
@@ -10,6 +10,8 @@ import { registerPerfTool } from './tools/perf.js';
10
10
  import { registerPerfBaselineTool } from './tools/perf-baseline.js';
11
11
  import { registerWaitTool } from './tools/wait.js';
12
12
  import { registerOperationLedgerTool } from './tools/operation-ledger.js';
13
+ import { registerScreenshotTool } from './tools/screenshot.js';
14
+ import { registerWatchCrashTool } from './tools/watch-crash.js';
13
15
  import { registerRpc } from './rpc.js';
14
16
  import { registerSkills } from './skill.js';
15
17
  export const name = 'dsh-adb';
@@ -21,24 +23,29 @@ export const Config = Schema.object({
21
23
  timeoutMs: Schema.number().default(30000).description('adb 命令超时(毫秒)'),
22
24
  baselineDir: Schema.string().description('adb_perf_baseline 基线存储目录;缺省 ~/.dsh/storages/dsh-adb'),
23
25
  reportDir: Schema.string().description('adb_device_report 报告存储目录;缺省 ~/.dsh/storages/dsh-adb/reports'),
26
+ screenshotDir: Schema.string().description('adb_screenshot 截图存储目录;缺省 ~/.dsh/storages/dsh-adb/screenshots'),
24
27
  });
25
28
  export function apply(ctx, config) {
26
29
  const cfg = config;
27
- const reportDir = config.reportDir ?? `${(config.baselineDir ?? DEFAULT_BASELINE_DIR).replace(/\\/g, '/')}/reports`;
30
+ const baselineDir = config.baselineDir ?? DEFAULT_BASELINE_DIR;
31
+ const reportDir = config.reportDir ?? `${baselineDir.replace(/\\/g, '/')}/reports`;
32
+ const screenshotDir = config.screenshotDir ?? `${baselineDir.replace(/\\/g, '/')}/screenshots`;
28
33
  registerDeviceTools(ctx, cfg);
29
34
  registerInstallTool(ctx, cfg);
30
35
  registerFileTool(ctx, cfg);
31
36
  registerLogcatTool(ctx, cfg);
32
37
  registerPerfTool(ctx, cfg);
33
- registerPerfBaselineTool(ctx, cfg, config.baselineDir ?? DEFAULT_BASELINE_DIR);
38
+ registerPerfBaselineTool(ctx, cfg, baselineDir);
34
39
  registerCrashReportTool(ctx, cfg);
35
40
  registerDeviceReportTool(ctx, cfg, reportDir);
36
41
  registerWaitTool(ctx, cfg);
37
- registerOperationLedgerTool(ctx, cfg, config.baselineDir ?? DEFAULT_BASELINE_DIR);
42
+ registerOperationLedgerTool(ctx, cfg, baselineDir);
43
+ registerScreenshotTool(ctx, cfg, screenshotDir);
44
+ registerWatchCrashTool(ctx, cfg);
38
45
  registerSkills(ctx);
39
46
  // The RPC channel needs the client connection, which mounts after this
40
47
  // plugin starts in web compositions; register lazily so headless profiles
41
48
  // (no connection) stay unaffected.
42
49
  ctx.inject(['connection'], (readyCtx) => registerRpc(readyCtx, cfg, reportDir));
43
- ctx.logger.info('[dsh-adb] loaded: 12 tools + web device panel rpc');
50
+ ctx.logger.info('[dsh-adb] loaded: 14 tools + web device panel rpc');
44
51
  }
@@ -0,0 +1,37 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type AdbConfig } from '../adb.js';
3
+ interface ScreenshotArgs {
4
+ serial?: string;
5
+ /** Local directory to save the PNG into; defaults to the plugin screenshotDir. */
6
+ dir?: string;
7
+ /** Basename for the PNG; defaults to <serial>-<epoch>.png. */
8
+ name?: string;
9
+ }
10
+ export interface ScreenshotResult {
11
+ savedTo: string;
12
+ bytes: number;
13
+ width?: number;
14
+ height?: number;
15
+ serial: string;
16
+ }
17
+ /** Stable local-path convention for screenshot saves: <dir>/<serial>-<epoch>.png. */
18
+ export declare function screenshotPathFor(dir: string, serial: string): string;
19
+ /**
20
+ * Capture the device screen into a local PNG: screencap on the device, pull to
21
+ * the local dir, then best-effort rm of the on-device temp. Returns the saved
22
+ * path, byte size, and (when the PNG header parses) pixel dimensions.
23
+ */
24
+ export declare function captureScreenshot(ctx: Context, cfg: AdbConfig, signal: AbortSignal, args: ScreenshotArgs, screenshotDir: string): Promise<ScreenshotResult>;
25
+ /** Parse PNG dimensions from the IHDR chunk (pure; unit-tested). */
26
+ export declare function pngDimensions(bytes: Uint8Array): {
27
+ width: number;
28
+ height: number;
29
+ } | undefined;
30
+ /**
31
+ * adb_screenshot: capture the device screen (screencap → pull) into a local
32
+ * PNG under the screenshot dir, and return its path + byte size. Screenshots
33
+ * are durable evidence: crash scenes, UI states, before/after test frames —
34
+ * the primitive behind the vision-based features (ROADMAP ③).
35
+ */
36
+ export declare function registerScreenshotTool(ctx: Context, cfg: AdbConfig, screenshotDir: string): void;
37
+ export {};
@@ -0,0 +1,88 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { AdbError, classifyFailure, jsonOutput, runAdb } from '../adb.js';
4
+ /** Stable local-path convention for screenshot saves: <dir>/<serial>-<epoch>.png. */
5
+ export function screenshotPathFor(dir, serial) {
6
+ return join(dir, `${serial.replace(/[^A-Za-z0-9._-]/g, '_')}-${Date.now()}.png`);
7
+ }
8
+ /**
9
+ * Capture the device screen into a local PNG: screencap on the device, pull to
10
+ * the local dir, then best-effort rm of the on-device temp. Returns the saved
11
+ * path, byte size, and (when the PNG header parses) pixel dimensions.
12
+ */
13
+ export async function captureScreenshot(ctx, cfg, signal, args, screenshotDir) {
14
+ const serial = args.serial ?? cfg.defaultSerial;
15
+ const devicePath = `/data/local/tmp/dsh-shot-${Date.now()}.png`;
16
+ const dir = args.dir ?? screenshotDir;
17
+ mkdirSync(dir, { recursive: true });
18
+ const localPath = args.name !== undefined
19
+ ? join(dir, args.name.endsWith('.png') ? args.name : `${args.name}.png`)
20
+ : screenshotPathFor(dir, serial ?? 'device');
21
+ try {
22
+ const cap = await runAdb(ctx, cfg, ['shell', 'screencap', '-p', devicePath], { signal, serial });
23
+ if (cap.exitCode !== 0)
24
+ throw classifyFailure(cap);
25
+ const pull = await runAdb(ctx, cfg, ['pull', devicePath, localPath], { signal, serial });
26
+ if (pull.exitCode !== 0)
27
+ throw classifyFailure(pull);
28
+ }
29
+ finally {
30
+ // Best-effort cleanup of the on-device temp file.
31
+ try {
32
+ await runAdb(ctx, cfg, ['shell', 'rm', '-f', devicePath], { signal, serial });
33
+ }
34
+ catch { /* cleanup is best-effort */ }
35
+ }
36
+ if (!existsSync(localPath)) {
37
+ throw new AdbError('SCREENSHOT_FAILED', `screencap succeeded but no file at ${localPath}`);
38
+ }
39
+ const bytes = readFileSync(localPath);
40
+ const dimensions = pngDimensions(bytes);
41
+ return {
42
+ savedTo: localPath,
43
+ bytes: bytes.length,
44
+ ...(dimensions !== undefined ? { width: dimensions.width, height: dimensions.height } : {}),
45
+ serial: serial ?? 'default',
46
+ };
47
+ }
48
+ /** Parse PNG dimensions from the IHDR chunk (pure; unit-tested). */
49
+ export function pngDimensions(bytes) {
50
+ // PNG signature (8 bytes) + IHDR length (4) + "IHDR" (4) + width (4) + height (4).
51
+ if (bytes.length < 24)
52
+ return undefined;
53
+ const sig = [137, 80, 78, 71, 13, 10, 26, 10];
54
+ for (let i = 0; i < 8; i++) {
55
+ if (bytes[i] !== sig[i])
56
+ return undefined;
57
+ }
58
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
59
+ return {
60
+ width: view.getUint32(16),
61
+ height: view.getUint32(20),
62
+ };
63
+ }
64
+ /**
65
+ * adb_screenshot: capture the device screen (screencap → pull) into a local
66
+ * PNG under the screenshot dir, and return its path + byte size. Screenshots
67
+ * are durable evidence: crash scenes, UI states, before/after test frames —
68
+ * the primitive behind the vision-based features (ROADMAP ③).
69
+ */
70
+ export function registerScreenshotTool(ctx, cfg, screenshotDir) {
71
+ ctx.tools.register({
72
+ name: 'adb_screenshot',
73
+ description: 'Capture the device screen into a local PNG (screencap on device → pull to host) and return the saved path, byte size, and pixel dimensions. Screenshots are durable evidence for crash scenes, UI states, or before/after test frames. Saves under the screenshotDir (default ~/.dsh/storages/dsh-adb/screenshots).',
74
+ parameters: {
75
+ type: 'object',
76
+ additionalProperties: false,
77
+ properties: {
78
+ serial: { type: 'string', description: 'Target device serial; defaults to the plugin defaultSerial.' },
79
+ dir: { type: 'string', description: 'Local directory to save into; defaults to the plugin screenshotDir.' },
80
+ name: { type: 'string', description: 'Basename for the PNG; defaults to <serial>-<epoch>.png.' },
81
+ },
82
+ },
83
+ output: jsonOutput(),
84
+ async execute(args, exec) {
85
+ return captureScreenshot(ctx, cfg, exec.signal, args, screenshotDir);
86
+ },
87
+ });
88
+ }
@@ -0,0 +1,46 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type AdbConfig } from '../adb.js';
3
+ import { parseLogcat } from '../parsers/logcat.js';
4
+ import { type CrashChain } from '../report.js';
5
+ /**
6
+ * adb_watch_crash: watch the crash buffer for NEW real crashes. Foreground:
7
+ * poll until a new real crash appears or the budget expires (returns
8
+ * matched:false on timeout, not an error). Background: run as a job that keeps
9
+ * polling; job_output returns a text summary of each newly detected crash.
10
+ *
11
+ * Reuses the crash classification from the health report (real crash vs. boot
12
+ * markers), so "a new crash" means a real one — not another mtk-brm line.
13
+ */
14
+ export interface WatchCrashArgs {
15
+ serial?: string;
16
+ /** Overall watch budget in milliseconds; defaults to 60000, capped at 600000. */
17
+ timeoutMs?: number;
18
+ /** Poll interval in milliseconds; defaults to 1000. */
19
+ intervalMs?: number;
20
+ /** Include the following same-pid lines (stack) per crash in the result. */
21
+ withStacks?: boolean;
22
+ run_in_background?: boolean;
23
+ }
24
+ export interface CrashDetection {
25
+ matched: boolean;
26
+ waitedMs: number;
27
+ crashes: Array<{
28
+ time: string;
29
+ pid: string;
30
+ tag: string;
31
+ message: string;
32
+ stack: string[];
33
+ }>;
34
+ }
35
+ /** A stable identity for one crash occurrence: time+pid+tag+message. */
36
+ export declare function crashSignature(chain: CrashChain): string;
37
+ /** Map crash chains to the public detection shape. */
38
+ export declare function chainToDetection(chain: CrashChain, withStacks: boolean): CrashDetection['crashes'][number];
39
+ /** One poll: return chains not seen in `seen`, and update `seen`. */
40
+ export declare function detectNewCrashes(entries: ReturnType<typeof parseLogcat>, seen: Set<string>, withStacks: boolean): CrashDetection['crashes'];
41
+ /** Poll the crash buffer until a new real crash appears or the budget expires. */
42
+ export declare function watchForCrash(ctx: Context, cfg: AdbConfig, signal: AbortSignal, args: WatchCrashArgs): Promise<CrashDetection>;
43
+ /** Format detected crashes as a job_output-friendly text block. */
44
+ export declare function formatCrashDetections(detection: CrashDetection): string;
45
+ /** adb_watch_crash: watch the crash buffer for NEW real crashes (foreground poll or background job). */
46
+ export declare function registerWatchCrashTool(ctx: Context, cfg: AdbConfig): void;
@@ -0,0 +1,199 @@
1
+ import { AdbError, classifyFailure, jsonOutput, runAdb } from '../adb.js';
2
+ import { parseLogcat } from '../parsers/logcat.js';
3
+ import { classifyCrashBuffer } from '../report.js';
4
+ const DEFAULT_TIMEOUT_MS = 60_000;
5
+ const MAX_TIMEOUT_MS = 600_000;
6
+ const DEFAULT_INTERVAL_MS = 1_000;
7
+ /** A stable identity for one crash occurrence: time+pid+tag+message. */
8
+ export function crashSignature(chain) {
9
+ const sig = chain.signature;
10
+ return `${sig.time}|${sig.pid}|${sig.tag}|${sig.message}`;
11
+ }
12
+ /** Map crash chains to the public detection shape. */
13
+ export function chainToDetection(chain, withStacks) {
14
+ return {
15
+ time: chain.signature.time,
16
+ pid: chain.signature.pid,
17
+ tag: chain.signature.tag,
18
+ message: chain.signature.message,
19
+ stack: withStacks ? chain.following.map((entry) => `${entry.tag}: ${entry.message}`) : [],
20
+ };
21
+ }
22
+ /** One poll: return chains not seen in `seen`, and update `seen`. */
23
+ export function detectNewCrashes(entries, seen, withStacks) {
24
+ const summary = classifyCrashBuffer(entries);
25
+ const fresh = [];
26
+ for (const chain of summary.chains) {
27
+ const key = crashSignature(chain);
28
+ if (seen.has(key))
29
+ continue;
30
+ seen.add(key);
31
+ fresh.push(chainToDetection(chain, withStacks));
32
+ }
33
+ return fresh;
34
+ }
35
+ function sleep(ms, signal) {
36
+ return new Promise((resolve, reject) => {
37
+ if (signal?.aborted) {
38
+ reject(new Error('tool call aborted'));
39
+ return;
40
+ }
41
+ const timer = setTimeout(resolve, ms);
42
+ signal?.addEventListener('abort', () => {
43
+ clearTimeout(timer);
44
+ reject(new Error('tool call aborted'));
45
+ }, { once: true });
46
+ });
47
+ }
48
+ /** Poll the crash buffer until a new real crash appears or the budget expires. */
49
+ export async function watchForCrash(ctx, cfg, signal, args) {
50
+ const timeoutMs = Math.min(Math.floor(args.timeoutMs ?? DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
51
+ const intervalMs = Math.max(Math.floor(args.intervalMs ?? DEFAULT_INTERVAL_MS), 250);
52
+ const start = Date.now();
53
+ const deadline = start + timeoutMs;
54
+ const seen = new Set();
55
+ const withStacks = args.withStacks ?? true;
56
+ let lastError;
57
+ // Seed: remember every crash already in the buffer, so only NEW ones match.
58
+ try {
59
+ const seed = await runAdb(ctx, cfg, ['logcat', '-b', 'crash', '-v', 'threadtime', '-d'], { signal, serial: args.serial, maxBytes: 8 * 1024 * 1024 });
60
+ if (seed.exitCode !== 0)
61
+ throw classifyFailure(seed);
62
+ for (const chain of classifyCrashBuffer(parseLogcat(seed.stdout)).chains) {
63
+ seen.add(crashSignature(chain));
64
+ }
65
+ }
66
+ catch (error) {
67
+ lastError = error instanceof Error ? error.message : String(error);
68
+ }
69
+ while (true) {
70
+ try {
71
+ const result = await runAdb(ctx, cfg, ['logcat', '-b', 'crash', '-v', 'threadtime', '-d'], { signal, serial: args.serial, maxBytes: 8 * 1024 * 1024 });
72
+ if (result.exitCode !== 0)
73
+ throw classifyFailure(result);
74
+ const fresh = detectNewCrashes(parseLogcat(result.stdout), seen, withStacks);
75
+ if (fresh.length > 0) {
76
+ return { matched: true, waitedMs: Date.now() - start, crashes: fresh };
77
+ }
78
+ }
79
+ catch (error) {
80
+ lastError = error instanceof Error ? error.message : String(error);
81
+ }
82
+ if (Date.now() >= deadline) {
83
+ return {
84
+ matched: false,
85
+ waitedMs: timeoutMs,
86
+ crashes: [],
87
+ ...(lastError !== undefined ? { reason: lastError } : { reason: `no new crash within ${timeoutMs}ms` }),
88
+ };
89
+ }
90
+ await sleep(Math.min(intervalMs, deadline - Date.now()), signal);
91
+ }
92
+ }
93
+ /** Format detected crashes as a job_output-friendly text block. */
94
+ export function formatCrashDetections(detection) {
95
+ const lines = [`[adb_watch_crash] ${detection.matched ? `detected ${detection.crashes.length} new crash(es) after ${detection.waitedMs}ms` : `no new crash within ${detection.waitedMs}ms`}`];
96
+ for (const crash of detection.crashes) {
97
+ lines.push(`- ${crash.time} pid=${crash.pid} ${crash.tag}: ${crash.message}`);
98
+ for (const line of crash.stack)
99
+ lines.push(` ${line}`);
100
+ }
101
+ return lines.join('\n');
102
+ }
103
+ /** adb_watch_crash: watch the crash buffer for NEW real crashes (foreground poll or background job). */
104
+ export function registerWatchCrashTool(ctx, cfg) {
105
+ ctx.tools.register({
106
+ name: 'adb_watch_crash',
107
+ description: 'Watch the crash buffer for NEW real crashes. Foreground: poll until a new real crash (FATAL EXCEPTION / Fatal signal / SIG*) appears or the budget expires — returns matched:false on timeout (not an error), so you can react. Background (run_in_background): keeps polling as a job; job_output returns a text summary of each newly detected crash. Boot markers (mtk-brm) are not crashes. Pair with adb_crash_report / adb_screenshot to capture the scene when a crash is detected.',
108
+ parameters: {
109
+ type: 'object',
110
+ additionalProperties: false,
111
+ properties: {
112
+ serial: { type: 'string', description: 'Target device serial; defaults to the plugin defaultSerial.' },
113
+ timeoutMs: { type: 'integer', description: 'Overall watch budget in ms; defaults to 60000, capped at 600000.' },
114
+ intervalMs: { type: 'integer', description: 'Poll interval in ms; defaults to 1000, minimum 250.' },
115
+ withStacks: { type: 'boolean', description: 'Include the following same-pid lines (stack) per crash; defaults to true.' },
116
+ run_in_background: { type: 'boolean', description: 'Run as a background job and return a job id immediately.' },
117
+ },
118
+ },
119
+ output: jsonOutput(),
120
+ async execute(args, exec) {
121
+ if (args.run_in_background === true) {
122
+ return startBackgroundWatch(ctx, cfg, args, exec);
123
+ }
124
+ return watchForCrash(ctx, cfg, exec.signal, args);
125
+ },
126
+ });
127
+ }
128
+ function startBackgroundWatch(ctx, cfg, args, exec) {
129
+ const jobs = ctx.get('jobs');
130
+ if (jobs === undefined) {
131
+ throw new AdbError('JOBS_UNAVAILABLE', 'background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs');
132
+ }
133
+ if (exec.signal.aborted) {
134
+ const error = new Error('tool call aborted');
135
+ error.name = 'AbortError';
136
+ throw error;
137
+ }
138
+ // The background loop must outlive the tool call: no exec.signal in the loop.
139
+ const controller = new AbortController();
140
+ let latestDetection;
141
+ let done = false;
142
+ const id = jobs.start({
143
+ kind: 'adb-watch-crash',
144
+ label: `watch-crash${args.serial !== undefined ? ` ${args.serial}` : ''}`,
145
+ ...(exec.agent !== undefined ? { owner: exec.agent } : {}),
146
+ run: () => {
147
+ const loop = (async () => {
148
+ const start = Date.now();
149
+ const timeoutMs = Math.min(Math.floor(args.timeoutMs ?? DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
150
+ const intervalMs = Math.max(Math.floor(args.intervalMs ?? DEFAULT_INTERVAL_MS), 250);
151
+ const seen = new Set();
152
+ const withStacks = args.withStacks ?? true;
153
+ const deadline = start + timeoutMs;
154
+ let lastError;
155
+ try {
156
+ const seed = await runAdb(ctx, cfg, ['logcat', '-b', 'crash', '-v', 'threadtime', '-d'], { signal: controller.signal, serial: args.serial, maxBytes: 8 * 1024 * 1024 });
157
+ if (seed.exitCode !== 0)
158
+ throw classifyFailure(seed);
159
+ for (const chain of classifyCrashBuffer(parseLogcat(seed.stdout)).chains) {
160
+ seen.add(crashSignature(chain));
161
+ }
162
+ }
163
+ catch (error) {
164
+ lastError = error instanceof Error ? error.message : String(error);
165
+ }
166
+ while (!done && Date.now() < deadline) {
167
+ try {
168
+ const result = await runAdb(ctx, cfg, ['logcat', '-b', 'crash', '-v', 'threadtime', '-d'], { signal: controller.signal, serial: args.serial, maxBytes: 8 * 1024 * 1024 });
169
+ if (result.exitCode !== 0)
170
+ throw classifyFailure(result);
171
+ const fresh = detectNewCrashes(parseLogcat(result.stdout), seen, withStacks);
172
+ if (fresh.length > 0) {
173
+ latestDetection = { matched: true, waitedMs: Date.now() - start, crashes: fresh };
174
+ }
175
+ }
176
+ catch (error) {
177
+ lastError = error instanceof Error ? error.message : String(error);
178
+ }
179
+ await sleep(Math.min(intervalMs, deadline - Date.now()), controller.signal).catch(() => { });
180
+ }
181
+ done = true;
182
+ if (latestDetection === undefined) {
183
+ latestDetection = {
184
+ matched: false,
185
+ waitedMs: timeoutMs,
186
+ crashes: [],
187
+ ...(lastError !== undefined ? { reason: lastError } : { reason: `no new crash within ${timeoutMs}ms` }),
188
+ };
189
+ }
190
+ })();
191
+ return {
192
+ cancel: () => { done = true; },
193
+ done: loop.then(() => ({ exitCode: 0, signal: null })),
194
+ readOutput: () => latestDetection === undefined ? '' : formatCrashDetections(latestDetection),
195
+ };
196
+ },
197
+ });
198
+ return { kind: 'background', jobId: id };
199
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-adb",
3
- "version": "1.4.0",
4
- "description": "ADB device & bench operations for DeepSeek Harness: device discovery, structured logcat, apk install, file pull/push, performance snapshots, perf baselines, crash reports, one-click device health reports, condition waits, operation ledger w/ rollback, web device panel (autocomplete, live logcat, profiler)",
3
+ "version": "1.6.0",
4
+ "description": "ADB device & bench operations for DeepSeek Harness: device discovery, structured logcat, apk install, file pull/push, performance snapshots, perf baselines, crash reports, one-click device health reports, condition waits, operation ledger w/ rollback, screenshots, crash watchdog, web device panel (autocomplete, live logcat, profiler)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {