@widgex/wgx 0.0.0-stage → 0.4.2

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.
Files changed (131) hide show
  1. package/LICENSE.txt +49 -0
  2. package/README.md +33 -2
  3. package/ThirdPartyNotices.txt +4685 -0
  4. package/package.json +30 -4
  5. package/provider-clis.json +1211 -0
  6. package/python/XACTOR.md +146 -0
  7. package/python/browser_actor_host.py +456 -0
  8. package/python/cdp_page.py +363 -0
  9. package/python/harness_check.py +77 -0
  10. package/python/requirements.txt +1 -0
  11. package/python/test_line_endings.py +185 -0
  12. package/python/xactor/__init__.py +18 -0
  13. package/python/xactor/__main__.py +226 -0
  14. package/python/xactor/api.py +882 -0
  15. package/python/xactor/bridge.py +784 -0
  16. package/python/xactor/dom.py +117 -0
  17. package/python/xactor/man/add_card.md +89 -0
  18. package/python/xactor/man/agent-vs-fork.md +108 -0
  19. package/python/xactor/man/agent.md +333 -0
  20. package/python/xactor/man/ask_model.md +110 -0
  21. package/python/xactor/man/ask_reference.md +56 -0
  22. package/python/xactor/man/ask_user.md +103 -0
  23. package/python/xactor/man/attach_app.md +595 -0
  24. package/python/xactor/man/attach_ide.md +65 -0
  25. package/python/xactor/man/attach_ide_surface.md +31 -0
  26. package/python/xactor/man/await_user.md +214 -0
  27. package/python/xactor/man/bash.md +142 -0
  28. package/python/xactor/man/browser-driver.md +137 -0
  29. package/python/xactor/man/browser_activity.md +53 -0
  30. package/python/xactor/man/browser_attach.md +84 -0
  31. package/python/xactor/man/browser_close_tab.md +50 -0
  32. package/python/xactor/man/browser_detach.md +50 -0
  33. package/python/xactor/man/browser_exec.md +66 -0
  34. package/python/xactor/man/browser_focus_tab.md +47 -0
  35. package/python/xactor/man/browser_hybrid.md +47 -0
  36. package/python/xactor/man/browser_open.md +50 -0
  37. package/python/xactor/man/browser_open_tab.md +58 -0
  38. package/python/xactor/man/browser_plumbing.md +107 -0
  39. package/python/xactor/man/browser_screenshot.md +56 -0
  40. package/python/xactor/man/browser_snapshot.md +52 -0
  41. package/python/xactor/man/browser_tabs.md +67 -0
  42. package/python/xactor/man/bulletin.md +88 -0
  43. package/python/xactor/man/capture_page.md +54 -0
  44. package/python/xactor/man/cd.md +64 -0
  45. package/python/xactor/man/chess_move.md +53 -0
  46. package/python/xactor/man/children.md +89 -0
  47. package/python/xactor/man/close_task.md +14 -0
  48. package/python/xactor/man/compact.md +75 -0
  49. package/python/xactor/man/drive.md +140 -0
  50. package/python/xactor/man/drive_space.md +72 -0
  51. package/python/xactor/man/driving.md +168 -0
  52. package/python/xactor/man/edit_file.md +112 -0
  53. package/python/xactor/man/finding.md +194 -0
  54. package/python/xactor/man/fork_agent.md +45 -0
  55. package/python/xactor/man/glob.md +83 -0
  56. package/python/xactor/man/grep.md +115 -0
  57. package/python/xactor/man/harness.md +165 -0
  58. package/python/xactor/man/harness_check.md +70 -0
  59. package/python/xactor/man/harness_format.md +257 -0
  60. package/python/xactor/man/highlight.md +66 -0
  61. package/python/xactor/man/instruct.md +111 -0
  62. package/python/xactor/man/ls.md +63 -0
  63. package/python/xactor/man/man.md +86 -0
  64. package/python/xactor/man/merge_wiz.md +40 -0
  65. package/python/xactor/man/notebook.md +98 -0
  66. package/python/xactor/man/overview.md +89 -0
  67. package/python/xactor/man/permissions.md +137 -0
  68. package/python/xactor/man/phase.md +150 -0
  69. package/python/xactor/man/powershell.md +74 -0
  70. package/python/xactor/man/profiles.md +60 -0
  71. package/python/xactor/man/project_drive.md +49 -0
  72. package/python/xactor/man/read_bulletin.md +89 -0
  73. package/python/xactor/man/read_file.md +90 -0
  74. package/python/xactor/man/relay.md +216 -0
  75. package/python/xactor/man/reply.md +72 -0
  76. package/python/xactor/man/reply_message.md +78 -0
  77. package/python/xactor/man/report_bug.md +118 -0
  78. package/python/xactor/man/request_answer.md +142 -0
  79. package/python/xactor/man/request_merge_decisions.md +121 -0
  80. package/python/xactor/man/request_operator_action.md +152 -0
  81. package/python/xactor/man/request_review.md +122 -0
  82. package/python/xactor/man/request_user_action.md +126 -0
  83. package/python/xactor/man/return_card.md +71 -0
  84. package/python/xactor/man/run.md +99 -0
  85. package/python/xactor/man/sandbox.md +144 -0
  86. package/python/xactor/man/session_fork.md +74 -0
  87. package/python/xactor/man/session_harness.md +109 -0
  88. package/python/xactor/man/session_join.md +102 -0
  89. package/python/xactor/man/session_land.md +73 -0
  90. package/python/xactor/man/session_nudge.md +73 -0
  91. package/python/xactor/man/session_rest.md +73 -0
  92. package/python/xactor/man/session_scope.md +84 -0
  93. package/python/xactor/man/session_terminate.md +58 -0
  94. package/python/xactor/man/session_token.md +78 -0
  95. package/python/xactor/man/sessions.md +93 -0
  96. package/python/xactor/man/set_harness.md +15 -0
  97. package/python/xactor/man/spawn_swarm.md +80 -0
  98. package/python/xactor/man/submit_evidence.md +54 -0
  99. package/python/xactor/man/swarm_brief.md +30 -0
  100. package/python/xactor/man/task_status.md +82 -0
  101. package/python/xactor/man/tasks.md +47 -0
  102. package/python/xactor/man/todo_write.md +104 -0
  103. package/python/xactor/man/update_card.md +70 -0
  104. package/python/xactor/man/use_profile.md +90 -0
  105. package/python/xactor/man/use_widget.md +82 -0
  106. package/python/xactor/man/wait_for_task.md +105 -0
  107. package/python/xactor/man/widget-model.md +284 -0
  108. package/python/xactor/man/widgets.md +59 -0
  109. package/python/xactor/man/write_file.md +76 -0
  110. package/python/xactor/man/writing-a-widget.md +142 -0
  111. package/python/xactor/man/xylon.md +120 -0
  112. package/python/xactor/man/xylon_blame.md +80 -0
  113. package/python/xactor/man/xylon_read.md +131 -0
  114. package/python/xactor/man/xylon_search.md +125 -0
  115. package/python/xactor/manual.py +195 -0
  116. package/python/xactor/parent_watch.py +115 -0
  117. package/python/xactor/plan_mode.py +350 -0
  118. package/python/xactor/sandbox.py +856 -0
  119. package/python/xactor/seals.py +438 -0
  120. package/python/xactor/shell_hints.py +210 -0
  121. package/python/xactor/states/__init__.py +13 -0
  122. package/python/xactor/states/base.py +523 -0
  123. package/python/xactor/states/registry.py +274 -0
  124. package/python/xactor/stdlib_api.py +6345 -0
  125. package/python/xactor/widgets/__init__.py +1 -0
  126. package/python/xactor/widgets/github_tokens.py +314 -0
  127. package/python/xactor/widgets/google_aistudio.py +365 -0
  128. package/settings-schema.json +403 -0
  129. package/viewer/index.css +160 -0
  130. package/viewer/index.js +15 -0
  131. package/wgx.mjs +290659 -0
@@ -0,0 +1,80 @@
1
+ # api.xylon_blame(path, line, end_line=None, tree_name=None) -> dict
2
+ <!-- also: blame, who-wrote-this, line_origin, git_blame -->
3
+ <!-- group: Xylon -->
4
+
5
+ Which task, agent and tool call wrote a line of code. Not gated; writes nothing.
6
+
7
+ `git blame` says which commit last changed a line. This carries on from there
8
+ through WidgeX's own record:
9
+
10
+ 1. **Commit to task.** Each task records the commits it made, and that record
11
+ outlives its branch. A commit newer than the record is found through the open
12
+ tasks' working copies. A merge commit names the branch it merged.
13
+ 2. **Task to agent.** Every session that worked in the task is in its folder.
14
+ 3. **Agent to tool call.** The transcripts keep every tool call with its input.
15
+ An edit whose new text contains the line is the one that wrote it.
16
+
17
+ Use it when you need to know *why* a line is the way it is: the conversation
18
+ around the edit usually says.
19
+
20
+ ## Result
21
+
22
+ ```python
23
+ {"tree": "widgex", "file": "widgex-ide/extension/src/services/handoff.ts",
24
+ "lines": [
25
+ {"from": 41, "to": 41, "text": "export const HANDOFF_TAIL_MAX = 20_000;",
26
+ "commit": {"sha": "3f2a…", "subject": "Hand-offs keep the tail…",
27
+ "author": "…", "at": "2026-10-09T…"},
28
+ "task": {"title": "Switch models mid-conversation", "ref": "widgex/switch-models-1a2b3c4d",
29
+ "branch": "widgex/switch-models", "via": "task-record"},
30
+ "agent": {"node": "widgex/switch-models-1a2b3c4d/session2", "model": "claude-opus-5"},
31
+ "edit": {"tool": "Edit", "tool_use_id": "toolu_…", "at": "2026-10-09T…",
32
+ "turn": 14, "match": "exact", "excerpt": "…HANDOFF_TAIL_MAX = 20_000…"}}]}
33
+ ```
34
+
35
+ - Consecutive lines with the same commit and the same edit come back as one
36
+ entry, `from` to `to`.
37
+ - **`task.via`** says how the task was found: `task-record`, `worktree`,
38
+ `merge-subject`, or `uncommitted` for a line you or your task wrote and nobody
39
+ has committed yet.
40
+ - **`edit.match`**: `exact` means the edit's new text contains the line.
41
+ `file` means the call touched the file but the text is not in it. That is
42
+ usually a `sed` or a script run through the shell, and the attribution is a
43
+ best guess.
44
+ - **`commit: None`**: not committed yet. **`task: None`**: no task made that
45
+ commit, because a person did, or it predates WidgeX. **`edit: None`**: the
46
+ task is known but no tool call in its sessions touched the file.
47
+
48
+ ## Examples
49
+
50
+ Why is this line here?
51
+
52
+ ```python
53
+ b = await api.xylon_blame("src/services/handoff.ts", 41)
54
+ hit = b["lines"][0]
55
+ if hit["edit"]:
56
+ return await api.xylon_read_turns(hit["agent"]["node"], around=hit["edit"]["turn"])
57
+ return hit
58
+ ```
59
+
60
+ Who wrote this function?
61
+
62
+ ```python
63
+ b = await api.xylon_blame("src/services/handoff.ts", 68, end_line=80)
64
+ return [(l["from"], l["to"], l["task"] and l["task"]["title"]) for l in b["lines"]]
65
+ ```
66
+
67
+ ## Getting it wrong
68
+
69
+ **Reading `match: 'file'` as certain.** It names the last call that touched the
70
+ file before the commit, not one known to have written the line.
71
+
72
+ **Blaming a whole file.** At most 50 lines per call. For a file's history,
73
+ `git log --follow` through `api.bash` is the better tool.
74
+
75
+ **Passing another worktree's path without `tree_name`.** The tasks searched are
76
+ your own project's.
77
+
78
+ ## See also
79
+
80
+ `xylon_search`, `xylon_read`, `compact`
@@ -0,0 +1,131 @@
1
+ # api.xylon_list_trees() / xylon_get_tree / xylon_get_node / xylon_read_messages
2
+ <!-- also: xylon_list_trees, xylon_get_tree, xylon_get_node, xylon_read_messages -->
3
+ <!-- group: Xylon -->
4
+
5
+ The four read calls on the session tree. None is gated; none writes.
6
+
7
+ The path grammar is shared by three of the four, and it is most of what there is
8
+ to know, so they live on one page.
9
+
10
+ ## Session paths
11
+
12
+ A path is `{repo}/{issue}/{session}` under a project. Sessions are **files** in
13
+ an issue's directory and are always siblings — nothing nests.
14
+
15
+ - **`.`** — this session.
16
+ - **`..`** — the session yours came from, read off `parent` on your header.
17
+ - **a bare `sessionN`** — a sibling in your own issue.
18
+ - **anything with slashes** — absolute: `widgex-ide/fix-relay/session3`.
19
+
20
+ `tree_name` defaults to this session's own project.
21
+
22
+ **A relative path cannot be combined with a different `tree_name`.** You get
23
+ `Cannot use a relative node_path ('..') with a different tree_name ('other')` —
24
+ `..` has no meaning over there. Other refusals: an empty `node_path`, a session
25
+ that has no address yet ("nothing to be relative to"), and `..` on the first
26
+ session in an issue, which came from nothing.
27
+
28
+ ## The calls
29
+
30
+ ### `xylon_list_trees() -> dict`
31
+
32
+ ```python
33
+ {"working": [{"name", "sourceName", "path", "createdAt"}],
34
+ "saved": [{"name", "sizeBytes", "modifiedAt"}]}
35
+ ```
36
+
37
+ **Two lists, not one.** `working` trees are unpacked and live. `saved` trees are
38
+ `.wxylon` archives. A name can appear in both.
39
+
40
+ ### `xylon_get_tree(tree_name=None) -> dict`
41
+
42
+ The whole project, three levels deep: repos, the issues in each, and the
43
+ sessions in each issue. Headers only — no transcripts.
44
+
45
+ ### `xylon_get_node(node_path, tree_name=None) -> dict`
46
+
47
+ ```python
48
+ {"name": str, "path": str, "tree": str,
49
+ "hasCosm": bool, "cosmData": dict | None, "children": [...]}
50
+ ```
51
+
52
+ `path` and `tree` are **what your arguments resolved to** — the whole point of
53
+ them is that `.`, `..` and an omitted `tree_name` are otherwise opaque. `name`
54
+ is only the last segment, and on its own it identifies nothing: the first
55
+ session in every issue is called `session1`.
56
+
57
+ To find out what *you* are called, prefer the name in your system prompt. It is
58
+ `issue/session`, which is the form other agents address you by.
59
+
60
+ `children` is always empty: a session is a leaf, and its siblings are found by
61
+ listing the issue rather than by descending into it.
62
+
63
+ `hasCosm` false means there is no file at that path. `cosmData` is the header:
64
+ `summary`, `summaryKind` (`'handoff'` or `'final'`), `parent`, `branch`,
65
+ `files` — the repo-relative paths the session wrote, in the order it first
66
+ touched them — and `usage`, tokens and cost per `<provider>/<model>`.
67
+
68
+ ### `xylon_read_messages(node_path, tree_name=None) -> list[dict]`
69
+
70
+ The transcript, as a list of message objects. **This is large.** A full session
71
+ transcript can be a substantial fraction of your context. If what you want is
72
+ what was *said* — not what tools printed — `xylon_read_turns` returns only that,
73
+ a page at a time.
74
+
75
+ A sealed session's transcript is gzipped on disk and inflated here, so this
76
+ costs more than the header read above it whatever the file size suggests.
77
+
78
+ ## Examples
79
+
80
+ What did the session that spawned me do?
81
+
82
+ ```python
83
+ msgs = await api.xylon_read_messages("..")
84
+ return f"parent said {len(msgs)} things"
85
+ ```
86
+
87
+ What files have *I* written to so far?
88
+
89
+ ```python
90
+ me = await api.xylon_get_node(".")
91
+ return me["cosmData"].get("files") or "nothing yet"
92
+ ```
93
+
94
+ Reading a child's work after joining it:
95
+
96
+ ```python
97
+ r = await api.session_join(child_id, timeout=900)
98
+ if r["status"] == "done":
99
+ return await api.xylon_get_node(r["xylonNodePath"])
100
+ return r
101
+ ```
102
+
103
+ Finding a tree by name:
104
+
105
+ ```python
106
+ trees = await api.xylon_list_trees()
107
+ return [t["name"] for t in trees["working"]]
108
+ ```
109
+
110
+ ## Getting it wrong
111
+
112
+ **Expecting bash edits in `files`.** They are not there, and nothing warns you.
113
+
114
+ **Reading `..` as a matter of course.** A three-sentence `summary` on
115
+ `agent` costs a fraction of a transcript. The parent read is the
116
+ fallback for when the brief was not enough.
117
+
118
+ **Reading `files` as a diff.** It is a list of paths and says nothing about what
119
+ changed in them. `git diff` on the Issue's branch is what answers that.
120
+
121
+ **Passing `tree_name` alongside `..`.** Refused.
122
+
123
+ **Treating `xylon_list_trees` as a list.** It is a dict of two lists. Iterating
124
+ it gives you the strings `"working"` and `"saved"`.
125
+
126
+ **Expecting a write call.** There is none. Summaries and the file list are the
127
+ IDE's job.
128
+
129
+ ## See also
130
+
131
+ `xylon`, `xylon_search`, `agent`, `session_join`, `bash`
@@ -0,0 +1,125 @@
1
+ # api.xylon_search(query=None, who=None, tree_name=None, limit=20) / xylon_read_turns
2
+ <!-- also: xylon_read_turns -->
3
+ <!-- group: Xylon -->
4
+
5
+ Find past work by what was said in it, then read just the part that matters.
6
+ Neither is gated; neither writes.
7
+
8
+ Every conversation in a project is kept. These two calls search and read the
9
+ **prose** of them — what the user typed and what agents answered — and leave out
10
+ tool calls and their output, which is most of a transcript and almost never what
11
+ you are looking for.
12
+
13
+ ## `xylon_search(query=None, who=None, tree_name=None, limit=20) -> dict`
14
+
15
+ ```python
16
+ {"query": "notarize",
17
+ "total": 3, # tasks that matched, before limit
18
+ "tasks": [
19
+ {"tree": "widgex", "task": "widgex/release-signing",
20
+ "title": "Release signing on CI", "open": False,
21
+ "branch": "ci-signing", "updatedAt": "2026-10-09T…",
22
+ "bullets": ["CI signs from a keychain it creates for each job", …],
23
+ "mentions": 4,
24
+ "hits": [{"node": "widgex/release-signing/3f2a…", "turn": 12,
25
+ "who": "user", "at": "2026-10-09T…",
26
+ "text": "…why does notarize hang on the runner"}]}]}
27
+ ```
28
+
29
+ - **`query`** — every word must be in the same turn. Case is ignored and a word
30
+ matches inside a longer one, so `notariz` finds *notarize* and *notarization*.
31
+ `"a quoted phrase"` is matched whole. There is no stemming and nothing fuzzy:
32
+ a miss means it was not said in those words, so try another word before
33
+ concluding it never came up.
34
+ - **No query** lists the most recent tasks with what each built and its
35
+ conversations (`conversations: [{node, turns, brief?}]`) — the answer to "what
36
+ has been done here".
37
+ - **`who='user'`** keeps only what a person typed. That is the strongest signal
38
+ for what was *asked for* and *decided*; agents repeat a term across a dozen
39
+ replies for every once it was asked about. A prompt another agent relayed
40
+ carries `from` and counts as neither side.
41
+ - **`tree_name`** defaults to your own project. `'*'` searches every project on
42
+ this machine.
43
+
44
+ Tasks come best first: one named for the query, then by the best hit, the
45
+ user's own words ahead of an agent's, newer ahead of older. `hits` holds up to
46
+ three per task; `mentions` is how many turns matched in all.
47
+
48
+ `bullets` is what the task built, a line each. It is empty for a task nobody has
49
+ summarised yet — that says nothing about whether work was done.
50
+
51
+ **`reading` means the answer is partial.** The first search on a machine reads
52
+ every conversation once (seconds for hundreds of megabytes). If that is still
53
+ going after twenty seconds the call returns what it has with
54
+ `"reading": {"done": 120, "total": 271}`. Ask again.
55
+
56
+ ## `xylon_read_turns(node_path, tree_name=None, around=None, before=3, after=3, who=None, offset=0, limit=40, max_chars=4000) -> dict`
57
+
58
+ ```python
59
+ {"tree": "widgex", "node": "widgex/release-signing/3f2a…", "total": 58,
60
+ "turns": [{"n": 11, "who": "agent", "at": "…", "text": "…"},
61
+ {"n": 12, "who": "user", "at": "…", "text": "why does notarize hang…"}],
62
+ "more": True}
63
+ ```
64
+
65
+ `node_path` is a hit's `node`, or `.` / `..` as for `xylon_get_node`.
66
+
67
+ - **`around=turn`** returns that turn with `before` ahead of it and `after`
68
+ behind. This is how a hit is opened up.
69
+ - Without `around`, a page: `limit` turns from `offset`. `more` says whether
70
+ there are turns after the last one returned; `total` is how many there are.
71
+ - A turn longer than `max_chars` is cut and carries `clipped`. Raise `max_chars`
72
+ for the one turn you need whole rather than for a page.
73
+
74
+ Turn numbers are stable: a turn keeps its `n` as the conversation goes on.
75
+
76
+ ## Examples
77
+
78
+ Has this been dealt with before?
79
+
80
+ ```python
81
+ found = await api.xylon_search("notarize hang")
82
+ return [(t["title"], t["mentions"], t["hits"][:1]) for t in found["tasks"]]
83
+ ```
84
+
85
+ Open the best hit:
86
+
87
+ ```python
88
+ hit = found["tasks"][0]["hits"][0]
89
+ return await api.xylon_read_turns(hit["node"], around=hit["turn"])
90
+ ```
91
+
92
+ What did the user actually ask for in that conversation?
93
+
94
+ ```python
95
+ return await api.xylon_read_turns(hit["node"], who="user", limit=100)
96
+ ```
97
+
98
+ What has been done in this project lately?
99
+
100
+ ```python
101
+ recent = await api.xylon_search(limit=10)
102
+ return [(t["title"], t["bullets"]) for t in recent["tasks"]]
103
+ ```
104
+
105
+ ## Getting it wrong
106
+
107
+ **Reaching for `xylon_read_messages` to find something.** It returns one whole
108
+ transcript, tool output included, and you would have to know which one. Search,
109
+ then read turns.
110
+
111
+ **Searching for a sentence.** Every word has to be in one turn. Two or three
112
+ distinctive words find more than a description does.
113
+
114
+ **Treating no hits as "never discussed".** It is keyword search. The same thing
115
+ may have been called something else — and tool output is not searched at all, so
116
+ a string that only ever appeared in a log will not be found.
117
+
118
+ **Ignoring `reading`.** A partial answer with no hits is not a "no".
119
+
120
+ **Passing a hit's `node` with another project's `tree_name` omitted.** After a
121
+ `tree_name='*'` search, pass the task's `tree` along with the node.
122
+
123
+ ## See also
124
+
125
+ `xylon`, `xylon_list_trees`, `ask_reference`
@@ -0,0 +1,195 @@
1
+ """The bundled manual — one markdown page per api call and per WidgeX feature.
2
+
3
+ Pages live in ``man/`` beside this module and are read straight off disk. No
4
+ bridge call, no permission, and it works in every sandbox mode: the shell's
5
+ Python process runs with ``<extension>/python`` as its cwd, which puts that
6
+ directory on ``sys.path``, and ``sandbox._python_prefixes`` always allows reads
7
+ under a ``sys.path`` prefix. The read happens inside ``api_scope`` — the same
8
+ thing that lets ``read_file`` call a bare ``open()``.
9
+
10
+ Named ``manual.py`` rather than ``man.py`` so the module never sits ambiguously
11
+ beside the ``man/`` directory it reads, and so ``man/*.md`` means exactly "every
12
+ page" with no ``__init__.py`` to exclude from the glob.
13
+
14
+ A page's names are its filename stem plus anything in an ``also:`` comment, so
15
+ adding ``foo.md`` for a new ``api.foo`` is complete on its own. Only pages that
16
+ cover a family of calls need the comment. The alternative — a registry mapping
17
+ names to files — costs two edits per method and makes "the page exists but
18
+ nothing resolves to it" the *default* failure rather than an unusual one.
19
+ """
20
+
21
+ import difflib
22
+ import functools
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ # .resolve() because sandbox.py canonicalises both its allowed prefixes and the
27
+ # path being opened; an unresolved path here would be compared against resolved
28
+ # prefixes and could miss on a symlinked or short-name install path.
29
+ MAN_DIR = Path(__file__).resolve().parent / "man"
30
+
31
+ _ALSO = "<!-- also:"
32
+ _GROUP = "<!-- group:"
33
+
34
+ # How the index orders its sections. Anything with a group not named here is
35
+ # sorted alphabetically after these, so a new group appears rather than vanishes.
36
+ GROUP_ORDER = (
37
+ "Coding",
38
+ "Talking to the user",
39
+ "Subagents",
40
+ "Talking to other agents",
41
+ "Sessions",
42
+ "Xylon",
43
+ "Working on a website",
44
+ "Reporting a WidgeX problem",
45
+ "Documentation",
46
+ "Concepts",
47
+ )
48
+
49
+ # Front matter is only ever at the top; scanning the whole file would let a
50
+ # fenced code block containing an `also:` line hijack a page's names.
51
+ _FRONT_MATTER_LINES = 6
52
+
53
+
54
+ def _normalize(topic: str) -> str:
55
+ """Reduce a topic to its lookup key.
56
+
57
+ Agents reach for this call precisely when they are unsure, so the spellings
58
+ that arrive are the ones a model would guess: ``api.bash``, ``bash()``,
59
+ ``await api.session_join``, ``session join``. All of them should land.
60
+ """
61
+ text = topic.strip().lower()
62
+ for prefix in ("await ", "api.", "widgex_terminal.", "man "):
63
+ if text.startswith(prefix):
64
+ text = text[len(prefix):].strip()
65
+ text = text.rstrip("()").strip()
66
+ return text.replace(" ", "-").replace("_", "-")
67
+
68
+
69
+ def _strip_comment(line: str, marker: str) -> str:
70
+ """Pull the payload out of a ``<!-- marker: payload -->`` line."""
71
+ body = line[len(marker):].strip()
72
+ if body.endswith("-->"):
73
+ body = body[:-3]
74
+ return body.strip()
75
+
76
+
77
+ def _parse(path: Path) -> dict[str, Any]:
78
+ """Read one page and pull out its title, blurb, names and group."""
79
+ # utf-8-sig, never the default: Path.read_text() falls back to the locale
80
+ # encoding, which is cp1252 on a stock Windows box and mangles every em-dash
81
+ # in these pages. The -sig also drops a BOM that would otherwise end up
82
+ # glued to the leading '#' and break the title parse.
83
+ text = path.read_text(encoding="utf-8-sig")
84
+ lines = text.splitlines()
85
+
86
+ title = lines[0].lstrip("#").strip() if lines else path.stem
87
+ also: list[str] = []
88
+ group = "Other"
89
+ blurb = ""
90
+
91
+ for line in lines[1:_FRONT_MATTER_LINES]:
92
+ stripped = line.strip()
93
+ if stripped.startswith(_ALSO):
94
+ also = [n.strip() for n in _strip_comment(stripped, _ALSO).split(",") if n.strip()]
95
+ elif stripped.startswith(_GROUP):
96
+ group = _strip_comment(stripped, _GROUP) or "Other"
97
+ elif stripped and not stripped.startswith("<!--") and not blurb:
98
+ blurb = stripped
99
+
100
+ return {
101
+ "stem": path.stem,
102
+ "title": title,
103
+ "blurb": blurb,
104
+ "group": group,
105
+ "names": [path.stem, *also],
106
+ "text": text,
107
+ }
108
+
109
+
110
+ @functools.lru_cache(maxsize=1)
111
+ def _index() -> tuple[dict[str, dict[str, Any]], tuple[dict[str, Any], ...]]:
112
+ """Build ``({normalized name: page}, all pages)``. Cached; pages ship read-only."""
113
+ pages = [_parse(p) for p in sorted(MAN_DIR.glob("*.md"))]
114
+ by_name: dict[str, dict[str, Any]] = {}
115
+ for page in pages:
116
+ for name in page["names"]:
117
+ by_name.setdefault(_normalize(name), page)
118
+ return by_name, tuple(pages)
119
+
120
+
121
+ def _index_page(pages: tuple[dict[str, Any], ...]) -> str:
122
+ """The contents listing, generated so it cannot fall behind the pages."""
123
+ out = [
124
+ "# The WidgeX api manual",
125
+ "",
126
+ "`await api.man(name)` returns any page below. Names are forgiving —",
127
+ "`'bash'`, `'api.bash'` and `'bash()'` all reach the same page.",
128
+ "",
129
+ ]
130
+
131
+ grouped: dict[str, list[dict[str, Any]]] = {}
132
+ for page in pages:
133
+ grouped.setdefault(page["group"], []).append(page)
134
+
135
+ ordered = [g for g in GROUP_ORDER if g in grouped]
136
+ ordered += sorted(g for g in grouped if g not in GROUP_ORDER)
137
+
138
+ for group in ordered:
139
+ out.append(f"## {group}")
140
+ for page in sorted(grouped[group], key=lambda p: p["stem"]):
141
+ out.append(f" {page['stem']:<26} {page['blurb']}".rstrip())
142
+ out.append("")
143
+
144
+ return "\n".join(out).rstrip() + "\n"
145
+
146
+
147
+ def _miss(topic: str, key: str, by_name: dict[str, dict[str, Any]],
148
+ partial: list[dict[str, Any]]) -> str:
149
+ """What to say when nothing matched.
150
+
151
+ Returned, never raised — see ``lookup``.
152
+ """
153
+ out = [f"No manual page for {topic!r}."]
154
+
155
+ if partial:
156
+ stems = sorted({p["stem"] for p in partial})
157
+ out += ["", "Did you mean: " + ", ".join(stems) + "?"]
158
+ else:
159
+ close = difflib.get_close_matches(key, sorted(by_name), n=3, cutoff=0.6)
160
+ stems = list(dict.fromkeys(by_name[c]["stem"] for c in close))
161
+ if stems:
162
+ out += ["", "Closest matches: " + ", ".join(stems)]
163
+
164
+ out += ["", "`await api.man()` lists every topic."]
165
+ return "\n".join(out)
166
+
167
+
168
+ def lookup(topic: str | None = None) -> str:
169
+ """The page for ``topic``, or the index when it is omitted.
170
+
171
+ Never raises. An unknown topic comes back as prose suggesting near misses,
172
+ because this is the call an agent makes when it does *not* know something —
173
+ if getting the name wrong could abort the exec, the surrounding work dies
174
+ with it and guessing becomes the cheaper strategy. That is the exact
175
+ incentive this call exists to reverse.
176
+ """
177
+ by_name, pages = _index()
178
+
179
+ if not isinstance(topic, str) or not topic.strip():
180
+ return _index_page(pages)
181
+
182
+ key = _normalize(topic)
183
+ page = by_name.get(key)
184
+ if page is not None:
185
+ return page["text"]
186
+
187
+ # A prefix that reaches exactly one page is an answer, not a miss:
188
+ # 'session_ext' can only mean session_extend. Several pages means the agent
189
+ # is browsing a family ('browser'), so name them instead of guessing.
190
+ partial = [by_name[n] for n in sorted(by_name) if n.startswith(key)]
191
+ unique = {p["stem"]: p for p in partial}
192
+ if len(unique) == 1:
193
+ return next(iter(unique.values()))["text"]
194
+
195
+ return _miss(topic, key, by_name, partial)
@@ -0,0 +1,115 @@
1
+ """Leave when the process that started this daemon is gone.
2
+
3
+ The daemon's normal way out is EOF on stdin: the host closes the pipe, the read
4
+ loop stops, the interpreter exits. That depends on the pipe closing reaching
5
+ *this* process, and on Windows it often does not. `python3` there is commonly a
6
+ launcher — the Python install manager's, a venv redirector — that starts the
7
+ real `python.exe` as a child and relays stdio to it, and a host that dies or
8
+ calls `process.exit()` closes the launcher's pipe without the child ever seeing
9
+ EOF. Both processes then live on with nothing to read, naming their worktree on
10
+ the command line, and with their background commands standing in it: a closed
11
+ task's folder that can never be deleted. 138 of them were found on one machine,
12
+ almost all left behind by test runs in task worktrees.
13
+
14
+ So the host passes its own pid in `WIDGEX_PARENT_PID`, and this watches that
15
+ process directly. On Windows it holds a handle opened at startup, while the
16
+ parent was certainly alive, so a later process given the same pid cannot be
17
+ mistaken for it. Elsewhere it watches `getppid()` and, when a launcher sits in
18
+ between, polls the pid.
19
+
20
+ The variable is removed from the environment once read: commands this daemon
21
+ starts inherit its environment, and one of them starting a daemon of its own
22
+ must watch *its* parent, not ours.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import sys
29
+ import threading
30
+ import time
31
+ from typing import Callable
32
+
33
+ ENV = "WIDGEX_PARENT_PID"
34
+
35
+ _POLL_S = 2.0
36
+ _SYNCHRONIZE = 0x00100000
37
+ _INFINITE = 0xFFFFFFFF
38
+
39
+
40
+ def _parent_pid() -> int | None:
41
+ raw = os.environ.pop(ENV, None)
42
+ if not raw:
43
+ return None
44
+ try:
45
+ pid = int(raw)
46
+ except ValueError:
47
+ return None
48
+ return pid if pid > 0 else None
49
+
50
+
51
+ def watch(on_gone: Callable[[], None]) -> bool:
52
+ """Call `on_gone` from a background thread once the host process exits.
53
+
54
+ Returns whether a watch was started. Started before the sandbox is
55
+ installed, so the handle is opened while nothing refuses it.
56
+ """
57
+ pid = _parent_pid()
58
+ if pid is None or pid == os.getpid():
59
+ return False
60
+
61
+ if sys.platform == "win32":
62
+ import _winapi
63
+
64
+ try:
65
+ handle = _winapi.OpenProcess(_SYNCHRONIZE, False, pid)
66
+ except OSError:
67
+ # Gone already, or not ours to open. The first is worth acting on;
68
+ # the second cannot be told apart from it here, and a daemon that
69
+ # leaves because it could not watch is worse than one that does
70
+ # not watch — EOF still works wherever it reaches us.
71
+ return False
72
+
73
+ def wait() -> None:
74
+ _winapi.WaitForSingleObject(handle, _INFINITE)
75
+ on_gone()
76
+ else:
77
+ direct = os.getppid() == pid
78
+
79
+ def alive() -> bool:
80
+ if direct:
81
+ return os.getppid() == pid
82
+ try:
83
+ os.kill(pid, 0)
84
+ except ProcessLookupError:
85
+ return False
86
+ except PermissionError:
87
+ return True
88
+ return True
89
+
90
+ def wait() -> None:
91
+ while alive():
92
+ time.sleep(_POLL_S)
93
+ on_gone()
94
+
95
+ threading.Thread(target=wait, name="xactor-parent-watch", daemon=True).start()
96
+ return True
97
+
98
+
99
+ def leave() -> None:
100
+ """Stop what this daemon started, then exit without waiting on anything.
101
+
102
+ `os._exit` rather than a normal shutdown: a normal one is exactly what has
103
+ already failed to happen, and it waits on threads that may never finish.
104
+ Background commands are stopped first, since they are what actually stands
105
+ in the worktree.
106
+ """
107
+ # Only if it is loaded: the browser backend never imports it, and importing
108
+ # it here would run its module setup on the way out.
109
+ stdlib_api = sys.modules.get(f"{__package__}.stdlib_api")
110
+ try:
111
+ if stdlib_api is not None:
112
+ stdlib_api._stop_background_commands()
113
+ except BaseException: # noqa: BLE001 — leaving regardless
114
+ pass
115
+ os._exit(0)