@shanepadgett/tau-agent 0.1.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 +30 -0
- package/docs/extending-tau-agent.md +119 -0
- package/docs/subagents.md +57 -0
- package/docs/tui.md +107 -0
- package/extensions/appshot/README.md +11 -0
- package/extensions/appshot/capture.swift +191 -0
- package/extensions/appshot/index.ts +245 -0
- package/extensions/appshot/native-helper.ts +102 -0
- package/extensions/attention/README.md +23 -0
- package/extensions/attention/index.ts +71 -0
- package/extensions/auto-name/README.md +26 -0
- package/extensions/auto-name/index.ts +163 -0
- package/extensions/branch/README.md +22 -0
- package/extensions/branch/index.ts +145 -0
- package/extensions/branch/panel.ts +148 -0
- package/extensions/clear-screen/README.md +29 -0
- package/extensions/clear-screen/index.ts +75 -0
- package/extensions/commit/README.md +15 -0
- package/extensions/commit/commit-plan.ts +328 -0
- package/extensions/commit/git-change-set.ts +268 -0
- package/extensions/commit/index.ts +138 -0
- package/extensions/commit/review-ui.ts +745 -0
- package/extensions/explore/README.md +7 -0
- package/extensions/explore/autoread.ts +140 -0
- package/extensions/explore/find.ts +147 -0
- package/extensions/explore/grep.ts +749 -0
- package/extensions/explore/index.ts +17 -0
- package/extensions/explore/limits.ts +22 -0
- package/extensions/explore/ls.ts +113 -0
- package/extensions/explore/path-display.ts +41 -0
- package/extensions/explore/path-tree.ts +150 -0
- package/extensions/explore/read.ts +160 -0
- package/extensions/explore/result.ts +29 -0
- package/extensions/explore/traverse.ts +226 -0
- package/extensions/footer/README.md +23 -0
- package/extensions/footer/index.ts +534 -0
- package/extensions/footer/settings.ts +13 -0
- package/extensions/ideas/README.md +10 -0
- package/extensions/ideas/browser.ts +66 -0
- package/extensions/ideas/index.ts +31 -0
- package/extensions/ideas/store.ts +32 -0
- package/extensions/image-gen/README.md +11 -0
- package/extensions/image-gen/client.ts +186 -0
- package/extensions/image-gen/index.ts +152 -0
- package/extensions/manage-sessions/README.md +12 -0
- package/extensions/manage-sessions/index.ts +58 -0
- package/extensions/manage-sessions/manager-ui.ts +323 -0
- package/extensions/manage-sessions/sessions.ts +126 -0
- package/extensions/patch/README.md +48 -0
- package/extensions/patch/executor.ts +355 -0
- package/extensions/patch/index.ts +170 -0
- package/extensions/patch/matcher.ts +236 -0
- package/extensions/patch/parser.ts +347 -0
- package/extensions/patch/render.ts +247 -0
- package/extensions/patch/summary.ts +35 -0
- package/extensions/publish/README.md +11 -0
- package/extensions/publish/index.ts +225 -0
- package/extensions/qna/README.md +45 -0
- package/extensions/qna/additional-context-body.ts +51 -0
- package/extensions/qna/body-render.ts +6 -0
- package/extensions/qna/choice-question-body.ts +322 -0
- package/extensions/qna/index.ts +245 -0
- package/extensions/qna/inline-editor-row.ts +56 -0
- package/extensions/qna/input-question-body.ts +89 -0
- package/extensions/qna/model.ts +317 -0
- package/extensions/qna/panel.ts +240 -0
- package/extensions/qna/ui.ts +16 -0
- package/extensions/reference/README.md +47 -0
- package/extensions/reference/index.ts +76 -0
- package/extensions/reference/panel.ts +874 -0
- package/extensions/reference/settings.ts +29 -0
- package/extensions/run-summary/README.md +5 -0
- package/extensions/run-summary/index.ts +98 -0
- package/extensions/silent-command-runner/README.md +25 -0
- package/extensions/silent-command-runner/index.ts +468 -0
- package/extensions/silent-command-runner/settings.ts +65 -0
- package/extensions/soul/README.md +19 -0
- package/extensions/soul/index.ts +20 -0
- package/extensions/soul/prompt.ts +254 -0
- package/extensions/soul/settings.ts +13 -0
- package/extensions/stash/README.md +48 -0
- package/extensions/stash/browser.ts +51 -0
- package/extensions/stash/index.ts +55 -0
- package/extensions/stash/store.ts +36 -0
- package/extensions/subagent/README.md +34 -0
- package/extensions/subagent/agents/scout.md +85 -0
- package/extensions/subagent/agents/web-research.md +95 -0
- package/extensions/subagent/agents.ts +196 -0
- package/extensions/subagent/index.ts +214 -0
- package/extensions/subagent/render.ts +89 -0
- package/extensions/subagent/run.ts +325 -0
- package/extensions/tau/README.md +17 -0
- package/extensions/tau/index.ts +122 -0
- package/extensions/tau-help/README.md +3 -0
- package/extensions/tau-help/help.md +125 -0
- package/extensions/tau-help/index.ts +68 -0
- package/extensions/turn-budget/README.md +14 -0
- package/extensions/turn-budget/index.ts +150 -0
- package/extensions/turn-budget/settings.ts +35 -0
- package/extensions/web/README.md +13 -0
- package/extensions/web/codesearch.ts +79 -0
- package/extensions/web/exa.ts +101 -0
- package/extensions/web/html.ts +67 -0
- package/extensions/web/index.ts +13 -0
- package/extensions/web/limits.ts +11 -0
- package/extensions/web/tool-output.ts +54 -0
- package/extensions/web/webfetch.ts +177 -0
- package/extensions/web/websearch.ts +100 -0
- package/package.json +57 -0
- package/prompts/.gitkeep +1 -0
- package/prompts/cavemanify.md +25 -0
- package/prompts/implement.md +25 -0
- package/prompts/interview.md +48 -0
- package/prompts/plan-feature.md +30 -0
- package/prompts/plan-implementation.md +50 -0
- package/schemas/tau.schema.json +205 -0
- package/shared/agent-blocked.ts +12 -0
- package/shared/description.ts +28 -0
- package/shared/events.ts +135 -0
- package/shared/git.ts +41 -0
- package/shared/injected-context.ts +69 -0
- package/shared/jsonl-store.ts +108 -0
- package/shared/model-fallback/index.ts +278 -0
- package/shared/model-fallback/settings.ts +18 -0
- package/shared/model-fallback/types.ts +8 -0
- package/shared/ranges.ts +21 -0
- package/shared/settings/define.ts +15 -0
- package/shared/settings/files.ts +20 -0
- package/shared/settings/json.ts +41 -0
- package/shared/settings/load.ts +59 -0
- package/shared/settings/merge.ts +21 -0
- package/shared/settings/paths.ts +41 -0
- package/shared/settings/schema.ts +25 -0
- package/shared/settings/specs.ts +23 -0
- package/shared/text.ts +11 -0
- package/shared/tool-row-state.ts +43 -0
- package/skills/writing-preferences/SKILL.md +136 -0
- package/themes/.gitkeep +1 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @shanepadgett/tau-agent
|
|
2
|
+
|
|
3
|
+
Tau is a custom agentic harness built with Pi extensions: tools, commands, prompts, skills, and themes.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@shanepadgett/tau-agent
|
|
9
|
+
# or from git (monorepo root)
|
|
10
|
+
pi install git:github.com/shanepadgett/tau-agent
|
|
11
|
+
# local
|
|
12
|
+
pi install ./path/to/tau-agent
|
|
13
|
+
pi install ./path/to/tau-agent/packages/agent
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Development
|
|
17
|
+
|
|
18
|
+
From the monorepo root:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install --ignore-scripts
|
|
22
|
+
mise run check
|
|
23
|
+
pi -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Docs
|
|
27
|
+
|
|
28
|
+
- [Extending Tau Agent](./docs/extending-tau-agent.md) — public events and integration
|
|
29
|
+
- [Subagents](./docs/subagents.md) — custom agent definitions
|
|
30
|
+
- [TUI](./docs/tui.md) — shared UI components
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Extending Tau Agent
|
|
2
|
+
|
|
3
|
+
Tau Agent is a Pi extension harness. External integration uses Pi's native `pi.events` bus.
|
|
4
|
+
|
|
5
|
+
The caller and Tau Agent must be loaded in the same Pi runtime. External callers use string channel names and documented payloads. They do not import Tau Agent internals.
|
|
6
|
+
|
|
7
|
+
Only events documented in this file are public. Extensions run trusted in-process; event emitters can ask Tau Agent to do work.
|
|
8
|
+
|
|
9
|
+
Related:
|
|
10
|
+
|
|
11
|
+
- [Custom subagents](./subagents.md)
|
|
12
|
+
- [TUI components](./tui.md)
|
|
13
|
+
|
|
14
|
+
## `tau:autoread.requested`
|
|
15
|
+
|
|
16
|
+
Ask Tau Agent to read files and inject visible `tau.autoread` messages.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
pi.events.emit("tau:autoread.requested", {
|
|
20
|
+
source: "my-extension",
|
|
21
|
+
title: "Skill context",
|
|
22
|
+
cwd: ctx.cwd,
|
|
23
|
+
batchId,
|
|
24
|
+
files: [{ path: "skills/foo/SKILL.md" }],
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Fields:
|
|
29
|
+
|
|
30
|
+
- `source`: caller identifier shown in Tau metadata.
|
|
31
|
+
- `title`: optional display/context label.
|
|
32
|
+
- `cwd`: root used to resolve file paths.
|
|
33
|
+
- `batchId`: groups visible autoread messages.
|
|
34
|
+
- `files[].path`: file path relative to `cwd`.
|
|
35
|
+
|
|
36
|
+
Behavior:
|
|
37
|
+
|
|
38
|
+
- Tau reads each requested file.
|
|
39
|
+
- Tau injects visible `tau.autoread` messages.
|
|
40
|
+
- Missing or unreadable files produce visible failed autoread messages.
|
|
41
|
+
- `pi.events.emit(...)` does not return file contents and should not be treated as completion or ack.
|
|
42
|
+
|
|
43
|
+
## `tau:footer-item`
|
|
44
|
+
|
|
45
|
+
Publish a bottom-right footer item in Tau's status footer.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
pi.events.emit("tau:footer-item", {
|
|
49
|
+
id: "my-extension.status",
|
|
50
|
+
text: "syncing",
|
|
51
|
+
priority: 10,
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Fields:
|
|
56
|
+
|
|
57
|
+
- `id`: stable item id. Re-emitting the same id replaces the previous item.
|
|
58
|
+
- `text`: optional display text. Omit or clear to remove the item (implementation may treat empty/undefined as hide).
|
|
59
|
+
- `priority`: optional sort priority (higher shows first when the footer ranks items).
|
|
60
|
+
|
|
61
|
+
## `tau:agent.blocked`
|
|
62
|
+
|
|
63
|
+
Notify that Tau is blocked waiting on the user (confirmations, custom UI, attention).
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
pi.events.emit("tau:agent.blocked", {
|
|
67
|
+
source: "my-extension",
|
|
68
|
+
title: "Needs input",
|
|
69
|
+
body: "Answer the open question to continue.",
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Fields:
|
|
74
|
+
|
|
75
|
+
- `source`: optional caller id.
|
|
76
|
+
- `title`: optional short title.
|
|
77
|
+
- `body`: optional detail text.
|
|
78
|
+
|
|
79
|
+
Tau's attention extension listens for this event. Other packages can listen too for custom notifications.
|
|
80
|
+
|
|
81
|
+
## `tau:file-mutation.applied`
|
|
82
|
+
|
|
83
|
+
Emitted after Tau's `patch` tool applies file changes.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
pi.events.on("tau:file-mutation.applied", (data) => {
|
|
87
|
+
// data.source === "patch"
|
|
88
|
+
// data.status: "completed" | "partial" | "failed"
|
|
89
|
+
// data.changes: path, kind, line stats, optional move/snapshotRanges
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Fields:
|
|
94
|
+
|
|
95
|
+
- `source`: currently `"patch"`.
|
|
96
|
+
- `toolCallId`: tool call that produced the mutation.
|
|
97
|
+
- `cwd`: working directory for the tool call.
|
|
98
|
+
- `status`: overall result.
|
|
99
|
+
- `changes[]`: per-file change summary (`path`, `kind`, optional `move`, `linesAdded`, `linesRemoved`, optional `snapshotRanges`).
|
|
100
|
+
|
|
101
|
+
Use this to react after mutations (formatters, review hooks, status UI). Do not treat it as a request channel.
|
|
102
|
+
|
|
103
|
+
## `tau:tool-row-state.set`
|
|
104
|
+
|
|
105
|
+
Set visual state on a Tau tool row (for example pruned).
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
pi.events.emit("tau:tool-row-state.set", {
|
|
109
|
+
rowId: "some-row-id",
|
|
110
|
+
state: "pruned",
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Fields:
|
|
115
|
+
|
|
116
|
+
- `rowId`: tool row id.
|
|
117
|
+
- `state`: optional visual state. Omit to clear.
|
|
118
|
+
|
|
119
|
+
Most extenders do not need this; it is for coordinating tool-row rendering with Tau's explore/patch/subagent tooling.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Custom subagents
|
|
2
|
+
|
|
3
|
+
Tau's `subagent` tool delegates one focused task to an isolated child Pi session. You can add your own agent definitions without writing TypeScript.
|
|
4
|
+
|
|
5
|
+
## Where definitions live
|
|
6
|
+
|
|
7
|
+
| Scope | Path | Use when |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| **User (global)** | `~/.pi/agent/tau/agents/*.md` | You want the agent in every project |
|
|
10
|
+
| **Project** | nearest trusted `.pi/tau/agents/*.md` | Repo-specific helpers |
|
|
11
|
+
|
|
12
|
+
Precedence: **project overrides user**, which overrides Tau's built-ins (`scout`, `web-research`). Duplicate names in one scope are invalid.
|
|
13
|
+
|
|
14
|
+
## Definition format
|
|
15
|
+
|
|
16
|
+
Markdown with frontmatter:
|
|
17
|
+
|
|
18
|
+
```markdown
|
|
19
|
+
---
|
|
20
|
+
name: api-reader
|
|
21
|
+
description: Inspect API declarations and usage
|
|
22
|
+
tools:
|
|
23
|
+
- read
|
|
24
|
+
- grep
|
|
25
|
+
model: openai-codex/gpt-5.4-mini
|
|
26
|
+
thinking: medium
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Stay within the delegated task. Return exact paths and symbols.
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Required:
|
|
33
|
+
|
|
34
|
+
- `name`
|
|
35
|
+
- `description`
|
|
36
|
+
- `tools` (unique tool names; `subagent` cannot be delegated)
|
|
37
|
+
|
|
38
|
+
Optional:
|
|
39
|
+
|
|
40
|
+
- `model` as `provider/model`
|
|
41
|
+
- `thinking`: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`
|
|
42
|
+
|
|
43
|
+
Named tools and configured models must exist in the normally loaded child Pi environment (including tools from installed packages such as Tau).
|
|
44
|
+
|
|
45
|
+
## Runtime rules
|
|
46
|
+
|
|
47
|
+
- Children use the parent's cwd and inherit model/thinking unless the definition overrides them.
|
|
48
|
+
- Children do not receive the parent conversation.
|
|
49
|
+
- At most four children run at once; extra calls wait in order.
|
|
50
|
+
- Returned text is capped (50 KB / 2,000 lines); full truncated output is saved to a private temp file.
|
|
51
|
+
|
|
52
|
+
## Built-ins
|
|
53
|
+
|
|
54
|
+
- `scout` — local exploration with `read`, `grep`, `find`, `ls`
|
|
55
|
+
- `web-research` — `websearch`, `codesearch`, `webfetch`
|
|
56
|
+
|
|
57
|
+
Ask Tau to delegate, or let it call `subagent` with an agent name and task.
|
package/docs/tui.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# TUI
|
|
2
|
+
|
|
3
|
+
Build TUI that mirrors Pi aesthetics.
|
|
4
|
+
|
|
5
|
+
## Reach order
|
|
6
|
+
|
|
7
|
+
1. Use Pi native UI when it fits:
|
|
8
|
+
- `ctx.ui.select()`
|
|
9
|
+
- `ctx.ui.confirm()`
|
|
10
|
+
- `ctx.ui.input()`
|
|
11
|
+
- `ctx.ui.editor()`
|
|
12
|
+
- `SelectList`
|
|
13
|
+
- `SettingsList`
|
|
14
|
+
- `BorderedLoader`
|
|
15
|
+
2. Use Tau shared TUI from `@shanepadgett/tau-tui` when a tool needs a custom component flow:
|
|
16
|
+
- `ToolPanel`
|
|
17
|
+
- `Tabs`
|
|
18
|
+
- `SelectableList`
|
|
19
|
+
3. If shared TUI lacks the needed behavior, decide if the missing piece should become a new shared component in `@shanepadgett/tau-tui`.
|
|
20
|
+
4. Build feature-local custom UI only when reuse is unlikely.
|
|
21
|
+
5. Custom components still use Pi TUI primitives from `@earendil-works/pi-tui`.
|
|
22
|
+
|
|
23
|
+
No one-off visual language. Make it look like Pi.
|
|
24
|
+
|
|
25
|
+
## Shared components
|
|
26
|
+
|
|
27
|
+
### `ToolPanel`
|
|
28
|
+
|
|
29
|
+
Baseline shell for focused custom flows. Use when Pi built-ins do not fit and the user needs a bordered panel with title, header, body, footer key hints, or acknowledgement.
|
|
30
|
+
|
|
31
|
+
For footer hints, child components expose hints, the parent combines the visible hints, and `ToolPanel` renders them.
|
|
32
|
+
|
|
33
|
+
### `Tabs`
|
|
34
|
+
|
|
35
|
+
Use inside a `ToolPanel` when one focused flow has multiple related views. Keeps tab switching out of feature code.
|
|
36
|
+
|
|
37
|
+
### `SelectableList`
|
|
38
|
+
|
|
39
|
+
Use inside a `ToolPanel` when the user needs cursor movement, single-select or multi-select behavior, optional inline filtering, and actions over current/selected/visible/older rows.
|
|
40
|
+
|
|
41
|
+
Filtering is configured with `filter: { searchText }`. Filtered single-select lists focus the filter immediately, so plain typing goes into the filter and action keys must use modified keys like `ctrl+n` or non-printable keys like `delete`. Filtered multi-select lists keep list focus until `/` focuses the filter; `Enter` applies the filter focus, and `Escape` clears it.
|
|
42
|
+
|
|
43
|
+
Shared components should stay generic. Feature behavior stays in the feature.
|
|
44
|
+
|
|
45
|
+
## Composition shape
|
|
46
|
+
|
|
47
|
+
Keep composition small:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { SelectableList, Tabs, ToolPanel } from "@shanepadgett/tau-tui";
|
|
51
|
+
|
|
52
|
+
const list = new SelectableList(theme, listConfig);
|
|
53
|
+
const archiveList = new SelectableList(theme, archiveListConfig);
|
|
54
|
+
|
|
55
|
+
const tabs = new Tabs(
|
|
56
|
+
theme,
|
|
57
|
+
[
|
|
58
|
+
{ id: "active", label: "Sessions", count: activeCount, body: list, getKeyHints: () => list.getKeyHints() },
|
|
59
|
+
{ id: "archive", label: "Archive", count: archiveCount, body: archiveList },
|
|
60
|
+
],
|
|
61
|
+
"active",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const panel = new ToolPanel(theme, {
|
|
65
|
+
title: "Manage sessions",
|
|
66
|
+
secondary: "scope: current",
|
|
67
|
+
body: tabs,
|
|
68
|
+
footer: { kind: "hints", hints: tabs.getKeyHints() },
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Key hints
|
|
73
|
+
|
|
74
|
+
Use `ToolKeyHint` helpers. Use `bindingHint` for one configurable Pi keybinding and `bindingsHint` for grouped bindings like `tui.select.up` + `tui.select.down` rendering as one `move` hint. Use `rawHint` only for fixed local keys.
|
|
75
|
+
|
|
76
|
+
Interactive shared components expose their own `getKeyHints()`. Composite components include visible child hints through explicit child providers such as `TabItem.getKeyHints`. Parents add only currently available domain/modal actions, then pass the final list to `ToolPanel`.
|
|
77
|
+
|
|
78
|
+
Do not render disabled actions as key hints. Put non-action text in `secondary`, `header`, `body`, or an acknowledgement footer message.
|
|
79
|
+
|
|
80
|
+
Do not hardcode key checks or rendered key labels for configurable actions. Keep key handling and key hints tied to the same binding.
|
|
81
|
+
|
|
82
|
+
## Widgets
|
|
83
|
+
|
|
84
|
+
Use `ctx.ui.setWidget(...)` for persistent, glanceable UI near the editor.
|
|
85
|
+
|
|
86
|
+
Good fits:
|
|
87
|
+
|
|
88
|
+
- persistent tool list
|
|
89
|
+
- todo list
|
|
90
|
+
- progress/status block that should not take focus
|
|
91
|
+
|
|
92
|
+
Do not use a widget for a focused flow that needs input ownership. Use `ctx.ui.custom(...)` and a component.
|
|
93
|
+
|
|
94
|
+
## Rules
|
|
95
|
+
|
|
96
|
+
- Every `render(width)` line must fit `width`.
|
|
97
|
+
- Use `truncateToWidth()` or `wrapTextWithAnsi()` for styled text.
|
|
98
|
+
- Plain text renderers should use Pi `Text` with `new Text("", 0, 0)` so tool rows keep native spacing and wrapping.
|
|
99
|
+
- Custom components must never return raw unbounded content. Split lines and run each line through `truncateToWidth()` or `wrapTextWithAnsi()` before returning it.
|
|
100
|
+
- Truncating styled text can insert resets that break row backgrounds. Prefer wrapping for styled text unless the component owns the whole line background.
|
|
101
|
+
- Custom components must implement `invalidate()`, even if it is a no-op.
|
|
102
|
+
- Use `theme` from the TUI callback. Do not import theme globals.
|
|
103
|
+
- Call `tui.requestRender()` after state changes.
|
|
104
|
+
- Keep feature behavior outside shared components.
|
|
105
|
+
- Keep storage and network outside TUI components.
|
|
106
|
+
- Prefer one composed panel over one giant component.
|
|
107
|
+
- If a helper has one caller and no useful name, inline it.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Appshot
|
|
2
|
+
|
|
3
|
+
Appshot gives Tau direct discovery and capture of macOS application windows. Tau can list visible windows as compact TOON with their application identity, title, process ID, bounds, and stable window ID; capture an exact window as a PNG for visual inspection; and bring an application forward when needed. Captures preserve aspect ratio and fit within 1568×1568 pixels to keep image payloads bounded.
|
|
4
|
+
|
|
5
|
+
The extension provides three tools:
|
|
6
|
+
|
|
7
|
+
- `list_windows` discovers visible normal windows.
|
|
8
|
+
- `screenshot_window` captures and inspects one listed window.
|
|
9
|
+
- `activate_app` brings a listed application to the foreground when visual validation requires it.
|
|
10
|
+
|
|
11
|
+
Appshot requires macOS 14 or newer. On first use, macOS asks for Screen & System Audio Recording access. If access is denied, open **System Settings → Privacy & Security → Screen & System Audio Recording** and grant access to the application running Tau, plus `tau-appshot` if macOS lists it separately.
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import Darwin
|
|
4
|
+
import Foundation
|
|
5
|
+
import ImageIO
|
|
6
|
+
import ScreenCaptureKit
|
|
7
|
+
import UniformTypeIdentifiers
|
|
8
|
+
|
|
9
|
+
enum AppshotError: LocalizedError {
|
|
10
|
+
case invalidArguments
|
|
11
|
+
case unsupportedOS
|
|
12
|
+
case processNotFound(pid_t)
|
|
13
|
+
case activationFailed(pid_t)
|
|
14
|
+
case permissionDenied
|
|
15
|
+
case windowNotFound(CGWindowID)
|
|
16
|
+
case imageDestination
|
|
17
|
+
case imageWrite
|
|
18
|
+
|
|
19
|
+
var errorDescription: String? {
|
|
20
|
+
switch self {
|
|
21
|
+
case .invalidArguments:
|
|
22
|
+
return "Usage: tau-appshot <list|capture|activate> [arguments]"
|
|
23
|
+
case .unsupportedOS:
|
|
24
|
+
return "Appshot requires macOS 14 or newer"
|
|
25
|
+
case let .processNotFound(pid):
|
|
26
|
+
return "No running application found for PID \(pid)"
|
|
27
|
+
case let .activationFailed(pid):
|
|
28
|
+
return "Could not activate application PID \(pid)"
|
|
29
|
+
case .permissionDenied:
|
|
30
|
+
return "Screen recording permission is required. In System Settings > Privacy & Security > Screen & System Audio Recording, grant access to the application running Tau (and tau-appshot if listed), then try again"
|
|
31
|
+
case let .windowNotFound(windowID):
|
|
32
|
+
return "No visible normal window found for window ID \(windowID); call list_windows again"
|
|
33
|
+
case .imageDestination:
|
|
34
|
+
return "Could not create the PNG destination"
|
|
35
|
+
case .imageWrite:
|
|
36
|
+
return "Could not write the captured PNG"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@main
|
|
42
|
+
struct TauAppshot {
|
|
43
|
+
static func main() async {
|
|
44
|
+
do {
|
|
45
|
+
guard #available(macOS 14.0, *) else {
|
|
46
|
+
throw AppshotError.unsupportedOS
|
|
47
|
+
}
|
|
48
|
+
let application = NSApplication.shared
|
|
49
|
+
application.setActivationPolicy(.prohibited)
|
|
50
|
+
application.finishLaunching()
|
|
51
|
+
|
|
52
|
+
guard CommandLine.arguments.count >= 2 else {
|
|
53
|
+
throw AppshotError.invalidArguments
|
|
54
|
+
}
|
|
55
|
+
switch CommandLine.arguments[1] {
|
|
56
|
+
case "list":
|
|
57
|
+
try await listWindows()
|
|
58
|
+
case "capture":
|
|
59
|
+
try await captureWindow()
|
|
60
|
+
case "activate":
|
|
61
|
+
try await activateApplication()
|
|
62
|
+
default:
|
|
63
|
+
throw AppshotError.invalidArguments
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
67
|
+
FileHandle.standardError.write(Data("\(message)\n".utf8))
|
|
68
|
+
exit(EXIT_FAILURE)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static func requireScreenCapturePermission() throws {
|
|
73
|
+
guard CGPreflightScreenCaptureAccess() || CGRequestScreenCaptureAccess() else {
|
|
74
|
+
throw AppshotError.permissionDenied
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@available(macOS 14.0, *)
|
|
79
|
+
static func listWindows() async throws {
|
|
80
|
+
guard CommandLine.arguments.count == 2 else {
|
|
81
|
+
throw AppshotError.invalidArguments
|
|
82
|
+
}
|
|
83
|
+
try requireScreenCapturePermission()
|
|
84
|
+
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
|
85
|
+
let windows: [[String: Any]] = content.windows
|
|
86
|
+
.filter({ window in
|
|
87
|
+
window.windowLayer == 0
|
|
88
|
+
&& window.frame.width > 0
|
|
89
|
+
&& window.frame.height > 0
|
|
90
|
+
&& window.owningApplication != nil
|
|
91
|
+
})
|
|
92
|
+
.sorted(by: { lhs, rhs in
|
|
93
|
+
let leftName = lhs.owningApplication?.applicationName ?? ""
|
|
94
|
+
let rightName = rhs.owningApplication?.applicationName ?? ""
|
|
95
|
+
if leftName != rightName {
|
|
96
|
+
return leftName.localizedCaseInsensitiveCompare(rightName) == .orderedAscending
|
|
97
|
+
}
|
|
98
|
+
return lhs.windowID < rhs.windowID
|
|
99
|
+
})
|
|
100
|
+
.map({ window in
|
|
101
|
+
let owner = window.owningApplication
|
|
102
|
+
return [
|
|
103
|
+
"window_id": Int(window.windowID),
|
|
104
|
+
"title": window.title ?? "",
|
|
105
|
+
"app_name": owner?.applicationName ?? "",
|
|
106
|
+
"bundle_id": owner?.bundleIdentifier ?? "",
|
|
107
|
+
"pid": Int(owner?.processID ?? 0),
|
|
108
|
+
"bounds": [
|
|
109
|
+
"x": window.frame.origin.x,
|
|
110
|
+
"y": window.frame.origin.y,
|
|
111
|
+
"width": window.frame.width,
|
|
112
|
+
"height": window.frame.height,
|
|
113
|
+
],
|
|
114
|
+
]
|
|
115
|
+
})
|
|
116
|
+
let data = try JSONSerialization.data(withJSONObject: windows, options: [.sortedKeys])
|
|
117
|
+
FileHandle.standardOutput.write(data)
|
|
118
|
+
FileHandle.standardOutput.write(Data("\n".utf8))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@available(macOS 14.0, *)
|
|
122
|
+
static func captureWindow() async throws {
|
|
123
|
+
guard CommandLine.arguments.count == 4,
|
|
124
|
+
let windowID = CGWindowID(CommandLine.arguments[2]),
|
|
125
|
+
windowID > 0
|
|
126
|
+
else {
|
|
127
|
+
throw AppshotError.invalidArguments
|
|
128
|
+
}
|
|
129
|
+
let outputPath = CommandLine.arguments[3]
|
|
130
|
+
try requireScreenCapturePermission()
|
|
131
|
+
|
|
132
|
+
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
|
133
|
+
guard let window = content.windows.first(where: {
|
|
134
|
+
$0.windowID == windowID
|
|
135
|
+
&& $0.windowLayer == 0
|
|
136
|
+
&& $0.frame.width > 0
|
|
137
|
+
&& $0.frame.height > 0
|
|
138
|
+
}) else {
|
|
139
|
+
throw AppshotError.windowNotFound(windowID)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let filter = SCContentFilter(desktopIndependentWindow: window)
|
|
143
|
+
let configuration = SCStreamConfiguration()
|
|
144
|
+
let pixelWidth = filter.contentRect.width * CGFloat(filter.pointPixelScale)
|
|
145
|
+
let pixelHeight = filter.contentRect.height * CGFloat(filter.pointPixelScale)
|
|
146
|
+
let scale = min(1, 1568 / max(pixelWidth, pixelHeight))
|
|
147
|
+
configuration.width = max(1, Int(floor(pixelWidth * scale)))
|
|
148
|
+
configuration.height = max(1, Int(floor(pixelHeight * scale)))
|
|
149
|
+
configuration.showsCursor = false
|
|
150
|
+
configuration.ignoreShadowsSingleWindow = true
|
|
151
|
+
|
|
152
|
+
let image = try await SCScreenshotManager.captureImage(
|
|
153
|
+
contentFilter: filter,
|
|
154
|
+
configuration: configuration
|
|
155
|
+
)
|
|
156
|
+
let outputURL = URL(fileURLWithPath: outputPath)
|
|
157
|
+
guard let destination = CGImageDestinationCreateWithURL(
|
|
158
|
+
outputURL as CFURL,
|
|
159
|
+
UTType.png.identifier as CFString,
|
|
160
|
+
1,
|
|
161
|
+
nil
|
|
162
|
+
) else {
|
|
163
|
+
throw AppshotError.imageDestination
|
|
164
|
+
}
|
|
165
|
+
CGImageDestinationAddImage(destination, image, nil)
|
|
166
|
+
guard CGImageDestinationFinalize(destination) else {
|
|
167
|
+
throw AppshotError.imageWrite
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
static func activateApplication() async throws {
|
|
172
|
+
guard CommandLine.arguments.count == 3,
|
|
173
|
+
let pid = pid_t(CommandLine.arguments[2]),
|
|
174
|
+
pid > 0
|
|
175
|
+
else {
|
|
176
|
+
throw AppshotError.invalidArguments
|
|
177
|
+
}
|
|
178
|
+
guard let application = NSRunningApplication(processIdentifier: pid) else {
|
|
179
|
+
throw AppshotError.processNotFound(pid)
|
|
180
|
+
}
|
|
181
|
+
guard application.activate(options: [.activateAllWindows]) else {
|
|
182
|
+
throw AppshotError.activationFailed(pid)
|
|
183
|
+
}
|
|
184
|
+
for _ in 0..<20 where !application.isActive {
|
|
185
|
+
try await Task.sleep(for: .milliseconds(100))
|
|
186
|
+
}
|
|
187
|
+
guard application.isActive else {
|
|
188
|
+
throw AppshotError.activationFailed(pid)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|