dsh-adb 1.2.0 → 1.4.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
@@ -40,6 +40,8 @@ Topics: `dsh-plugin` `dsh` `adb` `android` `automotive` `bench`
40
40
  | `adb_perf_baseline` | Perf regression: save a snapshot as a baseline (label/tags), compare current state and get a numeric diff (PSS, janky %, percentiles), list/delete baselines (stored locally under `baselineDir`) |
41
41
  | `adb_crash_report` | One-call crash scene: parsed logcat crash buffer + dropbox excerpt + process state + memory summary |
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
+ | `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
+ | `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 |
43
45
 
44
46
  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.
45
47
 
package/README.zh-CN.md CHANGED
@@ -40,6 +40,8 @@ Topics:`dsh-plugin` `dsh` `adb` `android` `automotive` `bench`
40
40
  | `adb_perf_baseline` | 性能回归:快照存基线(label/tags)、与当前状态数值对比(PSS/卡顿率/百分位)、list/delete(本地存储,`baselineDir`) |
41
41
  | `adb_crash_report` | 崩溃现场一键采集:crash buffer 解析 + dropbox 摘录 + 进程状态 + 内存摘要 |
42
42
  | `adb_device_report` | 一键体检:设备信息 + Top RSS 进程 + 崩溃缓冲(真实崩溃带堆栈/启动标记分类)+ W/E/F 日志按 tag 聚合 + 存储用量 + 健康结论;每节独立降级;落盘到 `reportDir` |
43
+ | `adb_wait_for` | 等待原语:等设备上线 / 启动完成 / 进程出现 / logcat 出现关键字,轮询到预算上限,替代盲目 sleep;超时返回 `matched:false` |
44
+ | `adb_operation_ledger` | 操作回滚台账(record/list/rollback):追加记录安装/推送等操作,查询历史,或回滚到最近一次成功安装的 APK(`adb install -r`);落盘 `operations.json` —— agent 自主改设备的信任基础 |
43
45
 
44
46
  错误码:`ADB_NOT_FOUND`、`ADB_UNAVAILABLE`、`DEVICE_NOT_FOUND`、`NO_DEVICES`、`CONNECT_FAILED`、`INSTALL_FAILED`、`ADB_EXIT_<code>` 等,均为结构化 `AdbError`。
45
47
 
package/lib/index.js CHANGED
@@ -8,6 +8,8 @@ import { registerInstallTool } from './tools/install.js';
8
8
  import { registerLogcatTool } from './tools/logcat.js';
9
9
  import { registerPerfTool } from './tools/perf.js';
10
10
  import { registerPerfBaselineTool } from './tools/perf-baseline.js';
11
+ import { registerWaitTool } from './tools/wait.js';
12
+ import { registerOperationLedgerTool } from './tools/operation-ledger.js';
11
13
  import { registerRpc } from './rpc.js';
12
14
  import { registerSkills } from './skill.js';
13
15
  export const name = 'dsh-adb';
@@ -31,10 +33,12 @@ export function apply(ctx, config) {
31
33
  registerPerfBaselineTool(ctx, cfg, config.baselineDir ?? DEFAULT_BASELINE_DIR);
32
34
  registerCrashReportTool(ctx, cfg);
33
35
  registerDeviceReportTool(ctx, cfg, reportDir);
36
+ registerWaitTool(ctx, cfg);
37
+ registerOperationLedgerTool(ctx, cfg, config.baselineDir ?? DEFAULT_BASELINE_DIR);
34
38
  registerSkills(ctx);
35
39
  // The RPC channel needs the client connection, which mounts after this
36
40
  // plugin starts in web compositions; register lazily so headless profiles
37
41
  // (no connection) stay unaffected.
38
42
  ctx.inject(['connection'], (readyCtx) => registerRpc(readyCtx, cfg, reportDir));
39
- ctx.logger.info('[dsh-adb] loaded: 10 tools + web device panel rpc');
43
+ ctx.logger.info('[dsh-adb] loaded: 12 tools + web device panel rpc');
40
44
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Device operation ledger ("回滚台账"): append-only record of device-modifying
3
+ * operations (install / uninstall / push / rm / replace), persisted as one
4
+ * JSON file, so the agent can list what it did and roll an app back to the
5
+ * last known-good APK. Storage mirrors the baseline store pattern; the
6
+ * rollback decision logic is pure and unit-tested.
7
+ */
8
+ export type OperationKind = 'install' | 'uninstall' | 'push' | 'rm' | 'replace' | 'other';
9
+ export interface OperationEntry {
10
+ id: string;
11
+ createdAt: string;
12
+ device: string;
13
+ kind: OperationKind;
14
+ /** App package id for install/uninstall/replace. */
15
+ package?: string;
16
+ /** Local APK path for install/replace (the rollback source). */
17
+ apk?: string;
18
+ /** Device path for push/rm. */
19
+ remotePath?: string;
20
+ result: 'ok' | 'failed';
21
+ note?: string;
22
+ }
23
+ export interface LedgerStoreData {
24
+ version: 1;
25
+ operations: OperationEntry[];
26
+ }
27
+ export interface LedgerStore {
28
+ list(): OperationEntry[];
29
+ record(entry: Omit<OperationEntry, 'id' | 'createdAt'>): OperationEntry;
30
+ /** The most recent successful install/replace of `package` on `device`, if any. */
31
+ latestGoodInstall(device: string, pkg: string): OperationEntry | undefined;
32
+ /** All successful install/replace entries for `device` + `package`, newest first. */
33
+ installHistory(device: string, pkg: string): OperationEntry[];
34
+ }
35
+ export declare function loadLedger(dir: string): LedgerStoreData;
36
+ export declare function saveLedger(dir: string, data: LedgerStoreData): void;
37
+ export declare function createLedgerStore(dir: string): LedgerStore;
38
+ export declare function describe(error: unknown): string;
@@ -0,0 +1,75 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ function filePath(dir) {
4
+ return `${dir.replace(/\\/g, '/')}/operations.json`;
5
+ }
6
+ function emptyData() {
7
+ return { version: 1, operations: [] };
8
+ }
9
+ export function loadLedger(dir) {
10
+ const file = filePath(dir);
11
+ if (!existsSync(file))
12
+ return emptyData();
13
+ let raw;
14
+ try {
15
+ raw = readFileSync(file, 'utf8');
16
+ }
17
+ catch (error) {
18
+ throw new Error(`operation ledger unreadable at ${file}: ${describe(error)}`);
19
+ }
20
+ try {
21
+ const data = JSON.parse(raw);
22
+ if (data?.version !== 1 || !Array.isArray(data.operations)) {
23
+ throw new Error('unexpected shape');
24
+ }
25
+ return data;
26
+ }
27
+ catch (error) {
28
+ const reason = error instanceof Error ? error.message : String(error);
29
+ throw new Error(`operation ledger corrupted at ${file}: ${reason}`);
30
+ }
31
+ }
32
+ export function saveLedger(dir, data) {
33
+ const file = filePath(dir);
34
+ try {
35
+ mkdirSync(dirname(file), { recursive: true });
36
+ writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
37
+ }
38
+ catch (error) {
39
+ throw new Error(`operation ledger unwritable at ${file}: ${describe(error)}`);
40
+ }
41
+ }
42
+ export function createLedgerStore(dir) {
43
+ return {
44
+ list() {
45
+ return loadLedger(dir).operations;
46
+ },
47
+ record(entry) {
48
+ const data = loadLedger(dir);
49
+ const stored = {
50
+ ...entry,
51
+ result: entry.result ?? 'ok',
52
+ id: `op-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
53
+ createdAt: new Date().toISOString(),
54
+ };
55
+ data.operations.push(stored);
56
+ saveLedger(dir, data);
57
+ return stored;
58
+ },
59
+ latestGoodInstall(device, pkg) {
60
+ return this.installHistory(device, pkg)[0];
61
+ },
62
+ installHistory(device, pkg) {
63
+ return loadLedger(dir).operations
64
+ .filter((entry) => entry.device === device
65
+ && entry.package === pkg
66
+ && (entry.kind === 'install' || entry.kind === 'replace')
67
+ && entry.result === 'ok'
68
+ && entry.apk !== undefined && entry.apk !== '')
69
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
70
+ },
71
+ };
72
+ }
73
+ export function describe(error) {
74
+ return error instanceof Error ? error.message : String(error);
75
+ }
@@ -0,0 +1,18 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type AdbConfig } from '../adb.js';
3
+ import { type OperationKind } from '../operation.js';
4
+ /** Validate a record payload; throws AdbError with a stable code on invalid input. */
5
+ export declare function validateLedgerRecord(args: {
6
+ kind?: OperationKind;
7
+ apk?: string;
8
+ remotePath?: string;
9
+ }): void;
10
+ /**
11
+ * adb_operation_ledger: append-only device operation ledger (回滚台账). Records
12
+ * device-modifying operations (install/uninstall/push/rm/replace) so the agent
13
+ * can list what it did and roll an app back to the last known-good APK. The
14
+ * ledger is the trust base for "agent modifies the device": without it the
15
+ * agent cannot undo what it changed. Persisted under the baseline dir as
16
+ * operations.json.
17
+ */
18
+ export declare function registerOperationLedgerTool(ctx: Context, cfg: AdbConfig, ledgerDir: string): void;
@@ -0,0 +1,119 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { AdbError, classifyFailure, jsonOutput, runAdb } from '../adb.js';
3
+ import { createLedgerStore } from '../operation.js';
4
+ const KINDS = ['install', 'uninstall', 'push', 'rm', 'replace', 'other'];
5
+ /** Validate a record payload; throws AdbError with a stable code on invalid input. */
6
+ export function validateLedgerRecord(args) {
7
+ if (args.kind === undefined)
8
+ throw new AdbError('ARGS_INVALID', 'record requires a "kind"');
9
+ if ((args.kind === 'install' || args.kind === 'replace') && (args.apk === undefined || !existsSync(args.apk))) {
10
+ throw new AdbError('LOCAL_FILE_NOT_FOUND', `install/replace record requires an existing local apk path, got: ${args.apk ?? '(none)'}`);
11
+ }
12
+ if ((args.kind === 'push' || args.kind === 'rm') && args.remotePath === undefined) {
13
+ throw new AdbError('ARGS_INVALID', 'push/rm record requires a "remotePath"');
14
+ }
15
+ }
16
+ /**
17
+ * adb_operation_ledger: append-only device operation ledger (回滚台账). Records
18
+ * device-modifying operations (install/uninstall/push/rm/replace) so the agent
19
+ * can list what it did and roll an app back to the last known-good APK. The
20
+ * ledger is the trust base for "agent modifies the device": without it the
21
+ * agent cannot undo what it changed. Persisted under the baseline dir as
22
+ * operations.json.
23
+ */
24
+ export function registerOperationLedgerTool(ctx, cfg, ledgerDir) {
25
+ ctx.tools.register({
26
+ name: 'adb_operation_ledger',
27
+ description: 'Append-only device operation ledger (回滚台账): record device-modifying operations (install/uninstall/push/rm/replace), list the history for a device and/or package, or roll an app back to its last known-good APK (re-installs the stored APK with -r). Use it after installs/pushes so the agent can undo its own device changes — the trust base for agent-driven device modification. Persisted under the baseline dir as operations.json.',
28
+ parameters: {
29
+ type: 'object',
30
+ additionalProperties: false,
31
+ required: ['command'],
32
+ properties: {
33
+ command: {
34
+ type: 'string',
35
+ enum: ['record', 'list', 'rollback'],
36
+ description: 'record: append one operation; list: show history (optionally filtered by serial/package); rollback: re-install the last known-good APK for a device+package.',
37
+ },
38
+ serial: { type: 'string', description: 'Target device serial; defaults to the plugin defaultSerial.' },
39
+ kind: {
40
+ type: 'string',
41
+ enum: [...KINDS],
42
+ description: 'Operation kind for record (install/uninstall/push/rm/replace/other).',
43
+ },
44
+ package: { type: 'string', description: 'App package id (install/uninstall/replace); required for rollback.' },
45
+ apk: { type: 'string', description: 'Local APK path for install/replace — the rollback source. Must exist for record+install/replace.' },
46
+ remotePath: { type: 'string', description: 'Device path for push/rm.' },
47
+ result: { type: 'string', enum: ['ok', 'failed'], description: 'Outcome of the recorded operation; defaults to ok.' },
48
+ note: { type: 'string', description: 'Free-form note (build id, reason, etc.).' },
49
+ limit: { type: 'integer', description: 'Cap for list entries; defaults to 20.' },
50
+ },
51
+ },
52
+ output: jsonOutput(),
53
+ async execute(args, exec) {
54
+ const store = createLedgerStore(ledgerDir);
55
+ const device = args.serial ?? cfg.defaultSerial ?? 'default';
56
+ if (args.command === 'list') {
57
+ const limit = args.limit !== undefined && args.limit > 0 ? Math.floor(args.limit) : 20;
58
+ let entries = store.list();
59
+ if (args.serial !== undefined)
60
+ entries = entries.filter((entry) => entry.device === args.serial);
61
+ if (args.package !== undefined)
62
+ entries = entries.filter((entry) => entry.package === args.package);
63
+ return {
64
+ command: 'list',
65
+ count: entries.length,
66
+ truncated: entries.length > limit,
67
+ entries: entries.slice(-limit).reverse().map((entry) => ({
68
+ id: entry.id,
69
+ createdAt: entry.createdAt,
70
+ device: entry.device,
71
+ kind: entry.kind,
72
+ ...(entry.package !== undefined ? { package: entry.package } : {}),
73
+ ...(entry.apk !== undefined ? { apk: entry.apk } : {}),
74
+ ...(entry.remotePath !== undefined ? { remotePath: entry.remotePath } : {}),
75
+ result: entry.result,
76
+ ...(entry.note !== undefined ? { note: entry.note } : {}),
77
+ })),
78
+ };
79
+ }
80
+ if (args.command === 'record') {
81
+ validateLedgerRecord(args);
82
+ const kind = args.kind;
83
+ const stored = store.record({
84
+ device,
85
+ kind,
86
+ ...(args.package !== undefined ? { package: args.package } : {}),
87
+ ...(args.apk !== undefined ? { apk: args.apk } : {}),
88
+ ...(args.remotePath !== undefined ? { remotePath: args.remotePath } : {}),
89
+ result: args.result ?? 'ok',
90
+ ...(args.note !== undefined ? { note: args.note } : {}),
91
+ });
92
+ return { command: 'record', recorded: { id: stored.id, createdAt: stored.createdAt } };
93
+ }
94
+ // rollback
95
+ if (args.package === undefined) {
96
+ throw new AdbError('ARGS_INVALID', 'rollback requires a "package"');
97
+ }
98
+ const target = store.latestGoodInstall(device, args.package);
99
+ if (target === undefined) {
100
+ throw new AdbError('LEDGER_EMPTY', `no recorded good install for device ${device} package ${args.package}; record installs with command=record first`);
101
+ }
102
+ if (target.apk === undefined || !existsSync(target.apk)) {
103
+ throw new AdbError('APK_MISSING', `rollback source missing: ${target.apk ?? '(none)'} (recorded ${target.createdAt})`);
104
+ }
105
+ const result = await runAdb(ctx, cfg, ['install', '-r', target.apk], { signal: exec.signal, serial: args.serial });
106
+ if (result.exitCode !== 0)
107
+ throw classifyFailure(result);
108
+ return {
109
+ command: 'rollback',
110
+ rolledBack: {
111
+ to: target.apk,
112
+ from: target.createdAt,
113
+ note: target.note,
114
+ },
115
+ message: `reinstalled ${target.apk} on ${device}`,
116
+ };
117
+ },
118
+ });
119
+ }
@@ -0,0 +1,42 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type AdbConfig } from '../adb.js';
3
+ import { type AdbDevice } from '../parsers/devices.js';
4
+ import { type LogcatEntry } from '../parsers/logcat.js';
5
+ import { type ProcessEntry } from '../parsers/sysinfo.js';
6
+ /**
7
+ * adb_wait_for: wait until a device reaches a condition, instead of sleeping a
8
+ * fixed number of seconds. Conditions: device-online, boot-complete, process
9
+ * (a process appears), logcat-pattern (a keyword appears). Polls at
10
+ * `intervalMs` until `timeoutMs`, then returns `matched: false` (not an error)
11
+ * so the agent can react to the timeout instead of guessing.
12
+ */
13
+ export type WaitCondition = 'device-online' | 'boot-complete' | 'process' | 'logcat-pattern';
14
+ export interface WaitArgs {
15
+ serial?: string;
16
+ condition: WaitCondition;
17
+ /** Substring matched against process names (condition=process) or logcat tag/message (condition=logcat-pattern). */
18
+ pattern?: string;
19
+ /** Overall wait budget in milliseconds; defaults to 30000, capped at 300000. */
20
+ timeoutMs?: number;
21
+ /** Poll interval in milliseconds; defaults to 1000. */
22
+ intervalMs?: number;
23
+ }
24
+ export interface WaitResult {
25
+ condition: WaitCondition;
26
+ matched: boolean;
27
+ waitedMs: number;
28
+ attempts: number;
29
+ reason?: string;
30
+ }
31
+ /** device-online: the serial is present in `adb devices -l` with state `device`. */
32
+ export declare function checkDeviceOnline(devices: AdbDevice[], serial?: string): boolean;
33
+ /** boot-complete: `getprop sys.boot_completed` output is exactly "1". */
34
+ export declare function checkBootComplete(getpropOut: string): boolean;
35
+ /** process: any process name contains the pattern. */
36
+ export declare function checkProcessPresent(processes: ProcessEntry[], pattern: string): boolean;
37
+ /** logcat-pattern: any entry's tag or message contains the keyword. */
38
+ export declare function checkLogcatKeyword(entries: LogcatEntry[], keyword: string): boolean;
39
+ /** Wait until the condition holds or the budget expires (returns matched:false on timeout). */
40
+ export declare function waitForCondition(ctx: Context, cfg: AdbConfig, signal: AbortSignal, args: WaitArgs): Promise<WaitResult>;
41
+ /** adb_wait_for: wait until a device condition holds (online / boot complete / process / logcat keyword). */
42
+ export declare function registerWaitTool(ctx: Context, cfg: AdbConfig): void;
@@ -0,0 +1,144 @@
1
+ import { classifyFailure, jsonOutput, runAdb } from '../adb.js';
2
+ import { parseDevices } from '../parsers/devices.js';
3
+ import { parseLogcat } from '../parsers/logcat.js';
4
+ import { parseProcessList } from '../parsers/sysinfo.js';
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+ const MAX_TIMEOUT_MS = 300_000;
7
+ const DEFAULT_INTERVAL_MS = 1_000;
8
+ // ---- Pure condition checks (unit-tested) ----
9
+ /** device-online: the serial is present in `adb devices -l` with state `device`. */
10
+ export function checkDeviceOnline(devices, serial) {
11
+ return devices.some((device) => {
12
+ if (serial !== undefined && device.serial !== serial)
13
+ return false;
14
+ return device.state === 'device';
15
+ });
16
+ }
17
+ /** boot-complete: `getprop sys.boot_completed` output is exactly "1". */
18
+ export function checkBootComplete(getpropOut) {
19
+ return getpropOut.trim() === '1';
20
+ }
21
+ /** process: any process name contains the pattern. */
22
+ export function checkProcessPresent(processes, pattern) {
23
+ return processes.some((entry) => entry.name.includes(pattern));
24
+ }
25
+ /** logcat-pattern: any entry's tag or message contains the keyword. */
26
+ export function checkLogcatKeyword(entries, keyword) {
27
+ return entries.some((entry) => entry.tag.includes(keyword) || entry.message.includes(keyword));
28
+ }
29
+ // ---- Poll loop ----
30
+ function sleep(ms, signal) {
31
+ return new Promise((resolve, reject) => {
32
+ if (signal?.aborted) {
33
+ reject(new Error('tool call aborted'));
34
+ return;
35
+ }
36
+ const timer = setTimeout(resolve, ms);
37
+ signal?.addEventListener('abort', () => {
38
+ clearTimeout(timer);
39
+ reject(new Error('tool call aborted'));
40
+ }, { once: true });
41
+ });
42
+ }
43
+ /** Run one adb probe for the condition; returns true when satisfied. */
44
+ async function probe(ctx, cfg, args, signal) {
45
+ const serial = args.serial;
46
+ switch (args.condition) {
47
+ case 'device-online': {
48
+ const result = await runAdb(ctx, cfg, ['devices', '-l'], { signal, maxBytes: 1024 * 1024 });
49
+ if (result.exitCode !== 0)
50
+ throw classifyFailure(result);
51
+ return checkDeviceOnline(parseDevices(result.stdout), args.serial);
52
+ }
53
+ case 'boot-complete': {
54
+ const result = await runAdb(ctx, cfg, ['shell', 'getprop', 'sys.boot_completed'], { signal, serial });
55
+ if (result.exitCode !== 0)
56
+ throw classifyFailure(result);
57
+ return checkBootComplete(result.stdout);
58
+ }
59
+ case 'process': {
60
+ const result = await runAdb(ctx, cfg, ['shell', 'ps', '-A'], { signal, serial, maxBytes: 2 * 1024 * 1024 });
61
+ if (result.exitCode !== 0)
62
+ throw classifyFailure(result);
63
+ return checkProcessPresent(parseProcessList(result.stdout), args.pattern);
64
+ }
65
+ case 'logcat-pattern': {
66
+ const result = await runAdb(ctx, cfg, ['logcat', '-v', 'threadtime', '-d'], { signal, serial, maxBytes: 8 * 1024 * 1024 });
67
+ if (result.exitCode !== 0)
68
+ throw classifyFailure(result);
69
+ return checkLogcatKeyword(parseLogcat(result.stdout), args.pattern);
70
+ }
71
+ default:
72
+ throw new Error(`unknown condition: ${String(args.condition)}`);
73
+ }
74
+ }
75
+ /** Validate args that fail fast — before the poll loop so they surface as errors, not timeouts. */
76
+ function validateArgs(args) {
77
+ if (!['device-online', 'boot-complete', 'process', 'logcat-pattern'].includes(args.condition)) {
78
+ throw new Error(`unknown condition: ${String(args.condition)}`);
79
+ }
80
+ if ((args.condition === 'process' || args.condition === 'logcat-pattern') && (args.pattern === undefined || args.pattern === '')) {
81
+ throw new Error(`condition "${args.condition}" requires a non-empty "pattern"`);
82
+ }
83
+ }
84
+ /** Wait until the condition holds or the budget expires (returns matched:false on timeout). */
85
+ export async function waitForCondition(ctx, cfg, signal, args) {
86
+ validateArgs(args);
87
+ const timeoutMs = Math.min(Math.floor(args.timeoutMs ?? DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
88
+ const intervalMs = Math.max(Math.floor(args.intervalMs ?? DEFAULT_INTERVAL_MS), 250);
89
+ const start = Date.now();
90
+ const deadline = start + timeoutMs;
91
+ let attempts = 0;
92
+ let lastProbe;
93
+ while (true) {
94
+ attempts++;
95
+ try {
96
+ const satisfied = await probe(ctx, cfg, args, signal);
97
+ if (satisfied) {
98
+ return { condition: args.condition, matched: true, waitedMs: Date.now() - start, attempts };
99
+ }
100
+ }
101
+ catch (error) {
102
+ // A transient probe failure (device offline mid-wait) is not terminal:
103
+ // keep polling until the budget, but record the reason.
104
+ lastProbe = error instanceof Error ? error.message : String(error);
105
+ }
106
+ if (Date.now() >= deadline) {
107
+ return {
108
+ condition: args.condition,
109
+ matched: false,
110
+ waitedMs: timeoutMs,
111
+ attempts,
112
+ ...(lastProbe !== undefined ? { reason: lastProbe } : { reason: `condition not met within ${timeoutMs}ms` }),
113
+ };
114
+ }
115
+ await sleep(Math.min(intervalMs, deadline - Date.now()), signal);
116
+ }
117
+ }
118
+ /** adb_wait_for: wait until a device condition holds (online / boot complete / process / logcat keyword). */
119
+ export function registerWaitTool(ctx, cfg) {
120
+ ctx.tools.register({
121
+ name: 'adb_wait_for',
122
+ description: 'Wait until a device reaches a condition, then return — instead of sleeping a fixed number of seconds. Conditions: device-online (device is in `adb devices` with state `device`), boot-complete (sys.boot_completed=1), process (a process whose name contains `pattern` appears in ps), logcat-pattern (a keyword appears in logcat tag/message). Polls every `intervalMs` up to `timeoutMs`; on timeout returns matched:false (not an error) so you can react. Use it to sequence multi-step device flows: install → wait for process → snapshot.',
123
+ parameters: {
124
+ type: 'object',
125
+ additionalProperties: false,
126
+ required: ['condition'],
127
+ properties: {
128
+ condition: {
129
+ type: 'string',
130
+ enum: ['device-online', 'boot-complete', 'process', 'logcat-pattern'],
131
+ description: 'Which condition to wait for.',
132
+ },
133
+ serial: { type: 'string', description: 'Target device serial; defaults to the plugin defaultSerial. For device-online, omit serial to wait for ANY device to come online.' },
134
+ pattern: { type: 'string', description: 'Required for process (process-name substring) and logcat-pattern (keyword).' },
135
+ timeoutMs: { type: 'integer', description: 'Overall wait budget in milliseconds; defaults to 30000, capped at 300000.' },
136
+ intervalMs: { type: 'integer', description: 'Poll interval in milliseconds; defaults to 1000, minimum 250.' },
137
+ },
138
+ },
139
+ output: jsonOutput(),
140
+ async execute(args, exec) {
141
+ return waitForCondition(ctx, cfg, exec.signal, args);
142
+ },
143
+ });
144
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-adb",
3
- "version": "1.2.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, web device panel (autocomplete, live logcat, profiler)",
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)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {