@vibelord/lanes 1.0.1 → 1.0.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.
- package/package.json +1 -1
- package/payload/lanes/README.md +37 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +651 -50
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +18 -3
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +1 -1
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +310 -26
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +80 -13
- package/payload/lanes/docs/DESIGN.md +182 -4
- package/payload/lanes/docs/RUNBOOK.md +429 -39
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +32 -0
- package/payload/lanes/libexec/cursor_sync.py +10 -2
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +285 -38
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +1 -1
- package/payload/lanes/libexec/session_actions.py +7 -8
- package/payload/lanes/libexec/sidebar.py +911 -218
- package/payload/lanes/libexec/statusd.py +238 -69
- package/payload/lanes/libexec/tokens.py +38 -7
- package/payload/lanes/libexec/view.sh +13 -3
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +83 -4
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +53 -198
- package/payload/lanes/web/mobile.js +87 -55
- package/payload/lanes/zellij/config.kdl +67 -16
- package/payload/manifest.json +4 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# BEGIN lanes tokens (generated by libexec/tokens.py --ghostty-light; do not edit by hand)
|
|
2
|
+
# Optional: the cockpit palette regardless of your everyday Ghostty theme (install.sh --client --palette).
|
|
3
|
+
# Slots 1-4 are the four tones, so 16-colour mode here equals truecolor mode. No font, padding or cursor keys.
|
|
4
|
+
background = #f3f5f2
|
|
5
|
+
foreground = #17241b
|
|
6
|
+
cursor-color = #2f6b45
|
|
7
|
+
selection-background = #d5ded6
|
|
8
|
+
selection-foreground = #17241b
|
|
9
|
+
palette = 0=#3b4a42
|
|
10
|
+
palette = 1=#b7322a
|
|
11
|
+
palette = 2=#1b7142
|
|
12
|
+
palette = 3=#8a5a00
|
|
13
|
+
palette = 4=#2456a8
|
|
14
|
+
palette = 5=#7a3fb8
|
|
15
|
+
palette = 6=#0f7b7b
|
|
16
|
+
palette = 7=#56616d
|
|
17
|
+
palette = 8=#5f6d66
|
|
18
|
+
palette = 9=#c2332a
|
|
19
|
+
palette = 10=#1b7a38
|
|
20
|
+
palette = 11=#8f5a00
|
|
21
|
+
palette = 12=#2860c4
|
|
22
|
+
palette = 13=#8a4dc8
|
|
23
|
+
palette = 14=#0f7272
|
|
24
|
+
palette = 15=#17241b
|
|
25
|
+
# END lanes tokens
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# BEGIN lanes tokens (generated by libexec/tokens.py --ghostty-dark; do not edit by hand)
|
|
2
|
+
# Optional: the cockpit palette regardless of your everyday Ghostty theme (install.sh --client --palette).
|
|
3
|
+
# Slots 1-4 are the four tones, so 16-colour mode here equals truecolor mode. No font, padding or cursor keys.
|
|
4
|
+
background = #101318
|
|
5
|
+
foreground = #edf1f5
|
|
6
|
+
cursor-color = #b4e3bf
|
|
7
|
+
selection-background = #2c333d
|
|
8
|
+
selection-foreground = #edf1f5
|
|
9
|
+
palette = 0=#7d8896
|
|
10
|
+
palette = 1=#f28b7d
|
|
11
|
+
palette = 2=#6fd39a
|
|
12
|
+
palette = 3=#e5b84a
|
|
13
|
+
palette = 4=#7fb3f2
|
|
14
|
+
palette = 5=#c9a6ff
|
|
15
|
+
palette = 6=#6fd6d0
|
|
16
|
+
palette = 7=#9ca7b5
|
|
17
|
+
palette = 8=#8a95a3
|
|
18
|
+
palette = 9=#ff9d96
|
|
19
|
+
palette = 10=#8fe0a3
|
|
20
|
+
palette = 11=#f7cf7f
|
|
21
|
+
palette = 12=#a5cbff
|
|
22
|
+
palette = 13=#dcc6ff
|
|
23
|
+
palette = 14=#9ae6e0
|
|
24
|
+
palette = 15=#edf1f5
|
|
25
|
+
# END lanes tokens
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Omarchy / Hyprland specifics for the lanes window (Ghostty GTK, dedicated instance).
|
|
2
|
-
# Look & feel is inherited from ~/.config/ghostty/config (Omarchy's default: theme include,
|
|
3
|
-
# Font, shift+enter as CSI-u, control+insert /
|
|
2
|
+
# Look & feel is inherited from ~/.config/ghostty/config (Omarchy's default: theme include, the Omarchy default font —
|
|
3
|
+
# a Nerd Font is not required by lanes, every glyph is plain Unicode; shift+enter as CSI-u, control+insert /
|
|
4
|
+
# shift+insert copy & paste), so Omarchy theme switches follow.
|
|
4
5
|
# Do NOT include the theme file here as well: Ghostty reports a second include of the same file as
|
|
5
6
|
# "config-file …: cycle detected" and opens its configuration-errors window.
|
|
6
7
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# lanes-launch [device] attach this terminal to the device's view session (default: see below)
|
|
4
4
|
# lanes-launch <workspace> same, and bring that workspace's tab to the front (e.g. `coder-launch worlds`)
|
|
5
5
|
# lanes-launch <device> --viewports N a wide view session: N (2-3) viewports side by side per workspace
|
|
6
|
+
# lanes-launch <device> [--ascii] [--theme dark|light|auto] [--glyphs M] [--color C] the device's look (lanes attach)
|
|
6
7
|
# lanes-launch lane <lane-id> attach straight to ONE lane, no multiplexer in between
|
|
7
8
|
# lanes-launch ls list lanes with live status
|
|
8
9
|
# lanes-launch --helper <device> [--service | --while-pid PID] run only the upload helper
|
|
@@ -41,11 +42,25 @@ case "${1:-}" in
|
|
|
41
42
|
lane)
|
|
42
43
|
[ -n "${2:-}" ] || { echo 'usage: lanes-launch lane <lane-id>' >&2; exit 64; }
|
|
43
44
|
mode=open target=$2 ;;
|
|
44
|
-
-h|--help) sed -n '2,
|
|
45
|
+
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
|
|
45
46
|
*) mode=attach target=${1:-$(default_device)} ;;
|
|
46
47
|
esac
|
|
47
|
-
if [ "$mode" = attach ] && [
|
|
48
|
-
|
|
48
|
+
if [ "$mode" = attach ] && [ $# -gt 1 ]; then
|
|
49
|
+
# Options after the device go to `lanes attach`. They are spliced into the remote command line, so only this closed
|
|
50
|
+
# vocabulary passes: a bad value refuses before any connection, an unknown option is said out loud and dropped.
|
|
51
|
+
shift
|
|
52
|
+
while [ $# -gt 0 ]; do
|
|
53
|
+
case "$1" in
|
|
54
|
+
--viewports) case "${2:-}" in 1|2|3) extra="$extra --viewports $2"; shift ;; *) echo '--viewports takes 1, 2 or 3' >&2; exit 64 ;; esac ;;
|
|
55
|
+
--ascii) extra="$extra --ascii" ;;
|
|
56
|
+
--theme) case "${2:-}" in dark|light|auto) extra="$extra --theme $2"; shift ;; *) echo '--theme takes dark, light or auto' >&2; exit 64 ;; esac ;;
|
|
57
|
+
--glyphs) case "${2:-}" in unicode|ascii|emoji) extra="$extra --glyphs $2"; shift ;; *) echo '--glyphs takes unicode, ascii or emoji' >&2; exit 64 ;; esac ;;
|
|
58
|
+
--color) case "${2:-}" in truecolor|16|none) extra="$extra --color $2"; shift ;; *) echo '--color takes truecolor, 16 or none' >&2; exit 64 ;; esac ;;
|
|
59
|
+
*) echo "lanes-launch: ignoring unknown option $1" >&2 ;;
|
|
60
|
+
esac
|
|
61
|
+
shift
|
|
62
|
+
done
|
|
63
|
+
extra=${extra# }
|
|
49
64
|
fi
|
|
50
65
|
case "$target" in ''|-*|*[!A-Za-z0-9_.-]*) echo "invalid name: $target" >&2; exit 64 ;; esac
|
|
51
66
|
client="$(hostname -s 2>/dev/null || echo client)-$$"
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Open the lanes window in its own terminal instance. usage: lanes-window [suffix]
|
|
2
|
+
# Open the lanes window in its own terminal instance. usage: lanes-window [--ascii] [--light|--dark] [suffix]
|
|
3
3
|
# suffix: an independent view session "<device>-<suffix>" (second monitor / other half of an ultrawide).
|
|
4
4
|
# w2 / w3: a WIDE view session with 2 / 3 viewports side by side per workspace (for a full-width window).
|
|
5
|
+
# --ascii / --light / --dark: the cockpit's look for this device (`lanes attach … --ascii|--theme`), remembered on
|
|
6
|
+
# the server in devices/<device>.json and picked up by a running sidebar and its status screens.
|
|
5
7
|
# macOS: Ghostty. Linux: Ghostty when installed, otherwise Foot with the same window class.
|
|
6
8
|
set -u
|
|
7
9
|
config=${XDG_CONFIG_HOME:-$HOME/.config}/lanes
|
|
@@ -9,11 +11,25 @@ launch=$HOME/.local/bin/lanes-launch
|
|
|
9
11
|
class=org.omarchy.lanes
|
|
10
12
|
device=
|
|
11
13
|
file=$config/ghostty.conf
|
|
14
|
+
look=
|
|
15
|
+
while [ $# -gt 0 ]; do
|
|
16
|
+
case "$1" in
|
|
17
|
+
--ascii) look="$look --ascii"; shift ;;
|
|
18
|
+
--light|--dark) look="$look --theme ${1#--}"; shift ;;
|
|
19
|
+
*) break ;;
|
|
20
|
+
esac
|
|
21
|
+
done
|
|
12
22
|
if [ -n "${1:-}" ]; then
|
|
13
23
|
case "$1" in *[!A-Za-z0-9_.-]*) echo 'invalid suffix' >&2; exit 64 ;; esac
|
|
14
24
|
device="$(head -n 1 "$config/device")-$1"
|
|
15
25
|
case "$1" in w2) device="$device --viewports 2" ;; w3) device="$device --viewports 3" ;; esac
|
|
16
26
|
file=$config/ghostty.$1.conf
|
|
27
|
+
elif [ -n "$look" ]; then
|
|
28
|
+
device=$(head -n 1 "$config/device")
|
|
29
|
+
file=$config/ghostty.look.conf
|
|
30
|
+
fi
|
|
31
|
+
if [ -n "$device" ]; then
|
|
32
|
+
device="$device$look"
|
|
17
33
|
sed "s|^command = .*|command = $launch $device|" "$config/ghostty.conf" > "$file"
|
|
18
34
|
fi
|
|
19
35
|
case "$(uname -s)" in
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
# (<ws>--<slug>), LANE_WS (<ws>), THROTTLE_TRACE and ANTHROPIC_CUSTOM_HEADERS ('x-throttle-trace: lanes/<ws>/<id>';
|
|
15
15
|
# trace charset [A-Za-z0-9._-/], at most 256). Never export them from a profile or a client: the client that starts
|
|
16
16
|
# a shared board socket's tmux server copies its env into the server's GLOBAL env, which every later session there
|
|
17
|
-
# without its own -e inherits (DEF-
|
|
17
|
+
# without its own -e inherits (DEF-1478) — so `lane open` strips them. A running lane gains its identity only when it
|
|
18
18
|
# is next created by a recipe carrying -e; a legacy recipe without -e gives none (`lanes cursor-sync --identity`).
|
|
19
19
|
if [ -z "${LANES_TMP_OPTOUT+set}" ] && [ -d "${LANES_TMP_DIR:-/home/coder/.tmp}" ] \
|
|
20
20
|
&& [ -w "${LANES_TMP_DIR:-/home/coder/.tmp}" ] && [ -x "${LANES_TMP_DIR:-/home/coder/.tmp}" ]; then
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{"hooks": {
|
|
2
|
+
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event UserPromptSubmit --harness claude", "timeout": 2}]}],
|
|
3
|
+
"PostToolUse": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event PostToolUse --harness claude", "timeout": 2}]}],
|
|
4
|
+
"Stop": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_input --event Stop --harness claude", "timeout": 2}]}],
|
|
5
|
+
"Notification": [{"matcher": "permission_prompt", "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_approval --event Notification --harness claude", "timeout": 2}]},
|
|
6
|
+
{"matcher": "idle_prompt", "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_input --event Notification --harness claude", "timeout": 2}]}],
|
|
7
|
+
"PermissionRequest": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_approval --event PermissionRequest --harness claude", "timeout": 2}]}],
|
|
8
|
+
"SessionEnd": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal shell --event SessionEnd --harness claude", "timeout": 2}]}],
|
|
9
|
+
"StopFailure": [{"hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal errored --event StopFailure --harness claude", "timeout": 2}]}]
|
|
10
|
+
}}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{"matcher": null, "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal idle --event SessionStart --harness codex", "timeout": 2}]}
|
|
5
|
+
],
|
|
6
|
+
"UserPromptSubmit": [
|
|
7
|
+
{"matcher": null, "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event UserPromptSubmit --harness codex", "timeout": 2}]}
|
|
8
|
+
],
|
|
9
|
+
"PostToolUse": [
|
|
10
|
+
{"matcher": null, "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal working --event PostToolUse --harness codex", "timeout": 2}]}
|
|
11
|
+
],
|
|
12
|
+
"PermissionRequest": [
|
|
13
|
+
{"matcher": null, "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal needs_approval --event PermissionRequest --harness codex", "timeout": 2}]}
|
|
14
|
+
],
|
|
15
|
+
"Stop": [
|
|
16
|
+
{"matcher": null, "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal done --event Stop --harness codex", "timeout": 2}]}
|
|
17
|
+
],
|
|
18
|
+
"SessionEnd": [
|
|
19
|
+
{"matcher": null, "hooks": [{"type": "command", "command": "~/.lz/bin/lane-signal shell --event SessionEnd --harness codex", "timeout": 2}]}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""lane — the in-container lane contract (Python 3 stdlib). Installed at /home/coder/.lz/bin/lane.
|
|
3
3
|
|
|
4
|
-
A lane is one long-lived agent terminal, held by a
|
|
5
|
-
|
|
4
|
+
A lane is one long-lived agent terminal, held by a tmux session (the lanes registry's `holder: tmux` lanes, named in
|
|
5
|
+
~/.lz/registry.json) or by a per-lane zmx daemon (`lane new`). Viewers, orchestrators and the web harness use ONLY
|
|
6
|
+
these verbs, so the holder stays swappable: every verb below resolves the id in ~/.lz/registry.json first and drives
|
|
7
|
+
a `holder: tmux` lane through its exact socket and session; any other id is a zmx lane, as before.
|
|
6
8
|
|
|
7
|
-
lane new <id> [--ws W] [--title T] [--cwd DIR] [--icon I] [--color C] -- <command...>
|
|
9
|
+
lane new <id> [--ws W] [--title T] [--cwd DIR] [--icon I] [--color C] -- <command...> (zmx only)
|
|
8
10
|
lane open <lane-id> the uniform entry point for editors (Cursor / VS Code terminals.json): attach to the
|
|
9
11
|
lane; if it does not exist, run its recorded create recipe. Never nests inside tmux.
|
|
10
12
|
While the lanes view shows the lane, attach with ignore-size (the lanes view owns the size).
|
|
11
|
-
lane attach <id> attach this terminal (never creates)
|
|
12
|
-
lane send <id> <text...>
|
|
13
|
+
lane attach <id> attach this terminal (never creates; a tmux lane never nests inside tmux)
|
|
14
|
+
lane send <id> [--no-enter] <text...> type into the lane. tmux: literally, then Enter unless --no-enter.
|
|
15
|
+
zmx: the bytes as given, never an Enter of its own (use $'\\r')
|
|
13
16
|
lane capture <id> [--lines N] [--vt] print scrollback (plain text, or VT with --vt)
|
|
14
|
-
lane ls [--json]
|
|
15
|
-
|
|
16
|
-
lane
|
|
17
|
+
lane ls [--json] [--all] zmx lanes with holder metadata; --all adds the registry's tmux lanes with `alive`
|
|
18
|
+
(the default listing stays zmx-only: Throttle Studio reads every row of it as zmx)
|
|
19
|
+
lane status <id> JSON: alive/running?, the hook agent (`lane-signal`), meta; exit 1 when not running
|
|
20
|
+
lane kill <id> --yes [--force] stop the lane (the ONLY verb that ends an agent); refuses while the lane's
|
|
21
|
+
hook sidecar says the agent is `working` unless --force
|
|
22
|
+
lane gc --sockets unlink this container's tmux socket files that tmux is refused on (ECONNREFUSED: no
|
|
23
|
+
server listens); prints each unlinked path. A live, empty or silent server keeps its file
|
|
17
24
|
"""
|
|
18
25
|
import json
|
|
19
26
|
import os
|
|
20
27
|
import pty
|
|
21
28
|
import re
|
|
22
29
|
import select
|
|
30
|
+
import socket
|
|
31
|
+
import stat
|
|
23
32
|
import subprocess
|
|
24
33
|
import sys
|
|
25
34
|
import time
|
|
@@ -29,7 +38,11 @@ ROOT = Path(os.environ.get('LANE_ROOT', '/home/coder/.lz'))
|
|
|
29
38
|
VERSION = os.environ.get('LANE_ZMX_VERSION', '0.8.1')
|
|
30
39
|
ZMX = ROOT / 'bin' / f'zmx-{VERSION}'
|
|
31
40
|
SOCKETS, META, STATE = ROOT / 's', ROOT / 'meta', ROOT / 'state'
|
|
41
|
+
HOOKS = STATE / 'hooks' # lane-signal's sidecars (never lane-run's phase records one directory up, DEF-1436)
|
|
42
|
+
PROC = Path('/proc') # this container's pid namespace: the hook's agent pid is checked here
|
|
32
43
|
IDENT = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$')
|
|
44
|
+
STATUSES = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
|
|
45
|
+
HOOK_MAX_AGE = 3600 # statusd.SIDECAR_MAX_AGE: an older last event is no agent
|
|
33
46
|
|
|
34
47
|
|
|
35
48
|
def zmx_env(extra=None):
|
|
@@ -154,7 +167,7 @@ IDENTITY = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # + a lanes/ ANTHROPIC_CUS
|
|
|
154
167
|
def identity_free(environ, drop=('TMUX',)):
|
|
155
168
|
"""`environ` without any lane identity (lanes/docs/ARCHITECTURE.md "Lane identity"). Whatever tmux client STARTS
|
|
156
169
|
a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
|
|
157
|
-
that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-
|
|
170
|
+
that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1478). Board sockets are shared by
|
|
158
171
|
many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
|
|
159
172
|
Identity therefore reaches a lane's shell ONLY through `-e` in its create recipe (lanes_lib.canonical_recipe).
|
|
160
173
|
An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays."""
|
|
@@ -162,9 +175,113 @@ def identity_free(environ, drop=('TMUX',)):
|
|
|
162
175
|
and not (k == 'ANTHROPIC_CUSTOM_HEADERS' and v.startswith('x-throttle-trace: lanes/'))}
|
|
163
176
|
|
|
164
177
|
|
|
178
|
+
def tmux_run(argv):
|
|
179
|
+
"""One tmux client, never carrying a lane identity (it could be the client that starts a shared board server)."""
|
|
180
|
+
return subprocess.run(argv, capture_output=True, text=True, timeout=15, env=identity_free(os.environ))
|
|
181
|
+
|
|
182
|
+
|
|
165
183
|
def tmux_cmd(socket, *args):
|
|
166
|
-
|
|
167
|
-
|
|
184
|
+
return tmux_run(['tmux', '-L', socket, *args])
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def registry_lanes(verb):
|
|
188
|
+
"""{lane id: entry} of ~/.lz/registry.json; {} when there is none (a zmx-only container needs none). A newer
|
|
189
|
+
registry is refused, as `lane open` refuses it."""
|
|
190
|
+
try:
|
|
191
|
+
data = json.loads((ROOT / 'registry.json').read_text())
|
|
192
|
+
lanes = data['lanes']
|
|
193
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
194
|
+
return {}
|
|
195
|
+
if int(data.get('version', 1)) > 1:
|
|
196
|
+
sys.exit(f'lane {verb}: the registry is newer than this `lane` — on the host run: lanes install-container <container>')
|
|
197
|
+
return lanes if isinstance(lanes, dict) else {}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def tmux_target(entry):
|
|
201
|
+
"""(socket, session) of a registry entry held by tmux, else None."""
|
|
202
|
+
if not isinstance(entry, dict) or entry.get('holder') != 'tmux':
|
|
203
|
+
return None
|
|
204
|
+
tmux = entry.get('tmux') or {}
|
|
205
|
+
socket, session = tmux.get('socket'), tmux.get('session')
|
|
206
|
+
return (str(socket), str(session)) if socket and session else None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def tmux_lane(verb, lane):
|
|
210
|
+
"""(entry, socket, session) when the registry holds `lane` as a tmux lane, else None (a zmx lane)."""
|
|
211
|
+
entry = registry_lanes(verb).get(lane)
|
|
212
|
+
target = tmux_target(entry)
|
|
213
|
+
return (entry, *target) if target else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def zmx_name(verb, lane):
|
|
217
|
+
"""The zmx session of `lane`: the registry's `zmx.name` for a registry zmx lane, else the id itself (`lane new`)."""
|
|
218
|
+
entry = registry_lanes(verb).get(lane)
|
|
219
|
+
name = ((entry or {}).get('zmx') or {}).get('name') if isinstance(entry, dict) and entry.get('holder') == 'zmx' else None
|
|
220
|
+
return name if isinstance(name, str) and IDENT.match(name) else lane
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def tmux_text(text):
|
|
224
|
+
"""tmux's argv parser ends a command at ONE trailing `;` of ANY argument and turns a trailing `\\;` into `;`
|
|
225
|
+
(MEASURED, tmux 3.4: `send-keys -l -- 'echo a;'` types `echo a`). Writing that last `;` as `\\;` types the text
|
|
226
|
+
exactly, a trailing `\\;` included."""
|
|
227
|
+
return text[:-1] + '\\;' if text.endswith(';') else text
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def send_argv(socket, session, text, enter=True):
|
|
231
|
+
"""Type `text` literally into the session's active pane, then Enter — one tmux client, `;`-separated. The target
|
|
232
|
+
is `=<session>:` (exact session, its current window): a bare `=<session>` is refused by send-keys and
|
|
233
|
+
capture-pane with "can't find pane" (MEASURED, tmux 3.4)."""
|
|
234
|
+
target = f'={session}:'
|
|
235
|
+
argv = ['tmux', '-L', socket, 'send-keys', '-t', target, '-l', '--', tmux_text(text)]
|
|
236
|
+
return argv + [';', 'send-keys', '-t', target, 'Enter'] if enter else argv
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def capture_argv(socket, session, lines, vt=False):
|
|
240
|
+
"""Joined scrollback of the session's active pane: the last `lines` history lines plus the screen (`-S -N`; the
|
|
241
|
+
caller keeps the last N non-blank lines), or all of it (`-S -`); `--vt` keeps the escape sequences (`-e`)."""
|
|
242
|
+
argv = ['tmux', '-L', socket, 'capture-pane', '-p', '-J', '-S', f'-{lines}' if lines else '-', '-t', f'={session}:']
|
|
243
|
+
return argv + ['-e'] if vt else argv
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def tmux_attach_argv(socket, session):
|
|
247
|
+
"""attach-session; with ignore-size while the lanes view shows the lane (the lanes view owns the size)."""
|
|
248
|
+
clients = tmux_cmd(socket, 'list-clients', '-t', f'={session}', '-F', '#{client_termname}').stdout.split()
|
|
249
|
+
argv = ['tmux', '-L', socket, 'attach-session']
|
|
250
|
+
if VIEWER_TERM in clients:
|
|
251
|
+
argv += ['-f', 'ignore-size'] # the lanes view is showing this lane: never resize it from here
|
|
252
|
+
return argv + ['-t', f'={session}']
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def hook_agent(name):
|
|
256
|
+
"""The lane's `lane-signal` sidecar (~/.lz/state/hooks/<name>.json) as an agent dict, or {} — judged as the host's
|
|
257
|
+
`agent_state.read_sidecar_dict` judges it: ours (`v` 1, a vocabulary word), a last event (`at`, else `since`)
|
|
258
|
+
within HOOK_MAX_AGE, and a recorded agent pid still alive in this container (and the same process when the
|
|
259
|
+
writer recorded its `start`). A crashed agent's last word is never an agent."""
|
|
260
|
+
try:
|
|
261
|
+
d = json.loads((HOOKS / f'{name}.json').read_text())
|
|
262
|
+
except (OSError, ValueError):
|
|
263
|
+
return {}
|
|
264
|
+
if not isinstance(d, dict) or d.get('v') != 1 or d.get('status') not in STATUSES:
|
|
265
|
+
return {}
|
|
266
|
+
try:
|
|
267
|
+
since = int(d.get('since') or 0)
|
|
268
|
+
at = int(d.get('at') or since)
|
|
269
|
+
pid = int(d.get('pid') or 0)
|
|
270
|
+
except (TypeError, ValueError):
|
|
271
|
+
return {}
|
|
272
|
+
if since <= 0 or time.time() - at > HOOK_MAX_AGE:
|
|
273
|
+
return {}
|
|
274
|
+
if pid > 0:
|
|
275
|
+
try:
|
|
276
|
+
stat = (PROC / str(pid) / 'stat').read_text()
|
|
277
|
+
except OSError:
|
|
278
|
+
return {}
|
|
279
|
+
if d.get('start') is not None:
|
|
280
|
+
fields = stat.rsplit(')', 1)[-1].split()
|
|
281
|
+
if len(fields) < 20 or fields[19] != str(d['start']):
|
|
282
|
+
return {}
|
|
283
|
+
return {'harness': str(d.get('harness') or ''), 'status': d['status'], 'since': since, 'at': at,
|
|
284
|
+
'event': str(d.get('event') or ''), 'session': str(d.get('session') or ''), 'pid': pid, 'source': 'hook'}
|
|
168
285
|
|
|
169
286
|
|
|
170
287
|
def cmd_open(args):
|
|
@@ -175,7 +292,7 @@ def cmd_open(args):
|
|
|
175
292
|
sys.exit(f"lane open: already inside tmux — refusing to nest. The lane is: {json.dumps(entry.get('tmux') or entry.get('zmx'))}")
|
|
176
293
|
# Nothing `lane open` execs carries a lane identity — neither this lane's nor one inherited from ANOTHER lane (an
|
|
177
294
|
# editor terminal started inside a lane): the recipe may be the first client on a shared board socket and would
|
|
178
|
-
# hand it to every later session there (DEF-
|
|
295
|
+
# hand it to every later session there (DEF-1478). The canonical recipe's own `-e` flags give the identity.
|
|
179
296
|
env = identity_free(os.environ, drop=('TMUX', 'TMUX_PANE'))
|
|
180
297
|
if entry.get('holder') == 'zmx':
|
|
181
298
|
name = entry['zmx']['name']
|
|
@@ -184,11 +301,7 @@ def cmd_open(args):
|
|
|
184
301
|
else:
|
|
185
302
|
socket, session = entry['tmux']['socket'], entry['tmux']['session']
|
|
186
303
|
if tmux_cmd(socket, 'has-session', '-t', f'={session}').returncode == 0:
|
|
187
|
-
|
|
188
|
-
argv = ['tmux', '-L', socket, 'attach-session']
|
|
189
|
-
if VIEWER_TERM in clients:
|
|
190
|
-
argv += ['-f', 'ignore-size'] # the lanes view is showing this lane: never resize it from here
|
|
191
|
-
os.execvpe('tmux', argv + ['-t', f'={session}'], env)
|
|
304
|
+
os.execvpe('tmux', tmux_attach_argv(socket, session), env)
|
|
192
305
|
# The lane does not exist: run the recipe the repository recorded for it (it creates AND attaches).
|
|
193
306
|
recipe = entry.get('create')
|
|
194
307
|
folder = entry.get('folder') or str(Path.home())
|
|
@@ -205,6 +318,15 @@ def cmd_open(args):
|
|
|
205
318
|
|
|
206
319
|
def cmd_attach(args):
|
|
207
320
|
name = lane_id(args[0] if args else '')
|
|
321
|
+
held = tmux_lane('attach', name)
|
|
322
|
+
if held:
|
|
323
|
+
_, socket, session = held
|
|
324
|
+
if os.environ.get('TMUX'):
|
|
325
|
+
sys.exit(f'lane attach: already inside tmux — refusing to nest. The lane is: {socket} {session}')
|
|
326
|
+
if tmux_cmd(socket, 'has-session', '-t', f'={session}').returncode != 0:
|
|
327
|
+
sys.exit(f'lane attach: {name} is not running (never created implicitly; `lane open {name}` runs its recipe)')
|
|
328
|
+
os.execvpe('tmux', tmux_attach_argv(socket, session), identity_free(os.environ, drop=('TMUX', 'TMUX_PANE')))
|
|
329
|
+
name = zmx_name('attach', name)
|
|
208
330
|
if not alive(name):
|
|
209
331
|
sys.exit(f'lane attach: {name} is not running (never created implicitly)')
|
|
210
332
|
# Clean environment on purpose: with ZMX_SESSION set, zmx would SWITCH the calling session instead of attaching.
|
|
@@ -212,23 +334,51 @@ def cmd_attach(args):
|
|
|
212
334
|
|
|
213
335
|
|
|
214
336
|
def cmd_send(args):
|
|
337
|
+
enter = '--no-enter' not in args[:2] # an option only before the text: a later `--no-enter` is text
|
|
338
|
+
if not enter:
|
|
339
|
+
args = list(args)
|
|
340
|
+
args.remove('--no-enter')
|
|
215
341
|
name = lane_id(args[0] if args else '')
|
|
216
|
-
|
|
342
|
+
text = ' '.join(args[1:])
|
|
343
|
+
held = tmux_lane('send', name)
|
|
344
|
+
if held:
|
|
345
|
+
_, socket, session = held
|
|
346
|
+
result = tmux_run(send_argv(socket, session, text, enter))
|
|
347
|
+
else:
|
|
348
|
+
result = zmx('send', zmx_name('send', name), text) # zmx: the bytes as given, never an Enter of its own
|
|
217
349
|
sys.stderr.write(result.stderr)
|
|
218
350
|
sys.exit(result.returncode)
|
|
219
351
|
|
|
220
352
|
|
|
353
|
+
def last_lines(text, lines):
|
|
354
|
+
"""The last `lines` lines that end in something: a tmux capture ends with the screen's unused blank rows."""
|
|
355
|
+
rows = text.splitlines()
|
|
356
|
+
while rows and not rows[-1].strip():
|
|
357
|
+
rows.pop()
|
|
358
|
+
return '\n'.join(rows[-lines:]) + '\n'
|
|
359
|
+
|
|
360
|
+
|
|
221
361
|
def cmd_capture(args):
|
|
222
362
|
lines = int(option(args, '--lines', '0') or 0)
|
|
223
363
|
flags = ['--vt'] if '--vt' in args else []
|
|
224
364
|
name = lane_id(next((a for a in args if not a.startswith('--')), ''))
|
|
225
|
-
|
|
365
|
+
held = tmux_lane('capture', name)
|
|
366
|
+
if held:
|
|
367
|
+
_, socket, session = held
|
|
368
|
+
result = tmux_run(capture_argv(socket, session, lines, vt=bool(flags)))
|
|
369
|
+
if result.returncode:
|
|
370
|
+
sys.stderr.write(result.stderr)
|
|
371
|
+
sys.exit(result.returncode)
|
|
372
|
+
sys.stdout.write(last_lines(result.stdout, lines) if lines else result.stdout)
|
|
373
|
+
return
|
|
374
|
+
text = zmx('history', zmx_name('capture', name), *flags).stdout
|
|
226
375
|
if lines:
|
|
227
376
|
text = '\n'.join(text.splitlines()[-lines:]) + '\n'
|
|
228
377
|
sys.stdout.write(text)
|
|
229
378
|
|
|
230
379
|
|
|
231
380
|
def describe():
|
|
381
|
+
"""The zmx lanes, one row each — the `lane ls --json` shape Throttle Studio reads as zmx lanes (lane_cli.rs)."""
|
|
232
382
|
rows = []
|
|
233
383
|
for name, fields in sorted(sessions().items()):
|
|
234
384
|
meta = {}
|
|
@@ -243,31 +393,87 @@ def describe():
|
|
|
243
393
|
pass
|
|
244
394
|
rows.append({'id': name, 'holder': 'zmx', 'pid': int(fields.get('pid', 0)), 'clients': int(fields.get('clients', 0)),
|
|
245
395
|
'ws': meta.get('ws', ''), 'title': meta.get('title', name), 'cwd': meta.get('cwd', ''),
|
|
246
|
-
'icon': meta.get('icon', ''), 'color': meta.get('color', ''), 'agent': state})
|
|
396
|
+
'icon': meta.get('icon', ''), 'color': meta.get('color', ''), 'agent': state, 'alive': True})
|
|
397
|
+
return rows
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def tmux_row(lane, entry, socket, session, alive_now):
|
|
401
|
+
"""A tmux lane: registry facts, `alive` from its server, `agent` from its hook sidecar ({} without one)."""
|
|
402
|
+
return {'id': lane, 'holder': 'tmux', 'ws': entry.get('ws') or '', 'title': entry.get('name') or lane,
|
|
403
|
+
'tmux': {'socket': socket, 'session': session}, 'alive': alive_now, 'agent': hook_agent(lane)}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def tmux_rows():
|
|
407
|
+
"""Every registry tmux lane, `alive` from ONE list-sessions per socket (a has-session per lane would be one tmux
|
|
408
|
+
client per lane; a socket whose server is not running lists nothing, so its lanes are not alive)."""
|
|
409
|
+
rows, listed = [], {}
|
|
410
|
+
for lane, entry in sorted(registry_lanes('ls').items()):
|
|
411
|
+
target = tmux_target(entry)
|
|
412
|
+
if not target or not IDENT.match(lane):
|
|
413
|
+
continue
|
|
414
|
+
socket, session = target
|
|
415
|
+
if socket not in listed:
|
|
416
|
+
result = tmux_cmd(socket, 'list-sessions', '-F', '#{session_name}')
|
|
417
|
+
listed[socket] = set(result.stdout.splitlines()) if result.returncode == 0 else set()
|
|
418
|
+
rows.append(tmux_row(lane, entry, socket, session, session in listed[socket]))
|
|
247
419
|
return rows
|
|
248
420
|
|
|
249
421
|
|
|
250
422
|
def cmd_ls(args):
|
|
251
|
-
|
|
423
|
+
# The default listing stays zmx-only: Throttle Studio reads EVERY `lane ls --json` row as a zmx lane, so tmux rows
|
|
424
|
+
# there would publish each registry tmux lane a second time. --all is the opt-in for people and scripts.
|
|
425
|
+
if '--all' in args:
|
|
426
|
+
try:
|
|
427
|
+
rows = describe()
|
|
428
|
+
except OSError: # no zmx binary in this root: no zmx lane can run here; the tmux lanes still list
|
|
429
|
+
rows = []
|
|
430
|
+
rows += tmux_rows()
|
|
431
|
+
else:
|
|
432
|
+
rows = describe() # unchanged: a missing zmx stays an error for the listing Studio reads
|
|
252
433
|
if '--json' in args:
|
|
253
434
|
print(json.dumps(rows, ensure_ascii=False, indent=1))
|
|
254
435
|
return
|
|
255
436
|
for row in rows:
|
|
437
|
+
if row['holder'] == 'tmux':
|
|
438
|
+
agent = row['agent'].get('status', '-')
|
|
439
|
+
print(f"{row['id']:<40} {row['ws']:<18} {'alive' if row['alive'] else 'dead':<9} {agent:<8} {row['title']}")
|
|
440
|
+
continue
|
|
256
441
|
agent = row['agent'].get('phase', '?')
|
|
257
442
|
print(f"{row['id']:<40} {row['ws']:<18} clients={row['clients']} {agent:<8} {row['title']}")
|
|
258
443
|
|
|
259
444
|
|
|
260
445
|
def cmd_status(args):
|
|
261
446
|
name = lane_id(args[0] if args else '')
|
|
262
|
-
|
|
447
|
+
held = tmux_lane('status', name)
|
|
448
|
+
if held:
|
|
449
|
+
entry, socket, session = held
|
|
450
|
+
alive_now = tmux_cmd(socket, 'has-session', '-t', f'={session}').returncode == 0
|
|
451
|
+
print(json.dumps(tmux_row(name, entry, socket, session, alive_now), ensure_ascii=False, indent=1))
|
|
452
|
+
sys.exit(0 if alive_now else 1)
|
|
453
|
+
zname = zmx_name('status', name)
|
|
454
|
+
row = next((r for r in describe() if r['id'] == zname), None)
|
|
455
|
+
if row:
|
|
456
|
+
row['hook'] = hook_agent(zname) # lane-run's phase record stays `agent` (DEF-1436)
|
|
263
457
|
print(json.dumps(row or {'id': name, 'running': False}, ensure_ascii=False, indent=1))
|
|
264
458
|
sys.exit(0 if row else 1)
|
|
265
459
|
|
|
266
460
|
|
|
267
461
|
def cmd_kill(args):
|
|
268
|
-
name = lane_id(args
|
|
269
|
-
|
|
270
|
-
|
|
462
|
+
name = lane_id(next((a for a in args if not a.startswith('--')), ''))
|
|
463
|
+
if '--yes' not in args:
|
|
464
|
+
sys.exit(f'lane kill: this ends the agent in {name} — confirm with --yes')
|
|
465
|
+
held = tmux_lane('kill', name)
|
|
466
|
+
target = name if held else zmx_name('kill', name)
|
|
467
|
+
if hook_agent(target).get('status') == 'working' and '--force' not in args:
|
|
468
|
+
sys.exit(f'lane kill: agent is working in {name} (its lane-signal hook says so); use --force')
|
|
469
|
+
if held:
|
|
470
|
+
_, socket, session = held
|
|
471
|
+
result = tmux_cmd(socket, 'kill-session', '-t', f'={session}')
|
|
472
|
+
stale = (HOOKS / f'{name}.json',) if result.returncode == 0 else ()
|
|
473
|
+
else:
|
|
474
|
+
result = zmx('kill', target)
|
|
475
|
+
stale = (META / f'{target}.json', STATE / f'{target}.json', HOOKS / f'{target}.json')
|
|
476
|
+
for path in stale:
|
|
271
477
|
try:
|
|
272
478
|
path.unlink()
|
|
273
479
|
except OSError:
|
|
@@ -276,8 +482,86 @@ def cmd_kill(args):
|
|
|
276
482
|
sys.exit(result.returncode)
|
|
277
483
|
|
|
278
484
|
|
|
485
|
+
GC_USAGE = 'usage: lane gc --sockets (unlink the tmux socket files in this container that no server listens on)'
|
|
486
|
+
GC_PROBE_TIMEOUT = 5
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def tmux_socket_dir():
|
|
490
|
+
"""Where this container's tmux servers put their sockets: $TMUX_TMPDIR (else /tmp) / tmux-<uid>, as tmux does."""
|
|
491
|
+
return Path(os.environ.get('TMUX_TMPDIR') or '/tmp') / f'tmux-{os.getuid()}'
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def socket_refused(path):
|
|
495
|
+
"""True only when connecting to the AF_UNIX socket file `path` fails with ECONNREFUSED (the file is there, nobody
|
|
496
|
+
listens: its server is gone). Anything else — a server that accepts, EACCES, ENOENT (gone meanwhile), a timeout,
|
|
497
|
+
a path too long for sun_path — is False: keep the file."""
|
|
498
|
+
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
499
|
+
probe.settimeout(GC_PROBE_TIMEOUT)
|
|
500
|
+
try:
|
|
501
|
+
probe.connect(str(path))
|
|
502
|
+
except ConnectionRefusedError:
|
|
503
|
+
return True
|
|
504
|
+
except OSError:
|
|
505
|
+
return False
|
|
506
|
+
finally:
|
|
507
|
+
probe.close()
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def cmd_gc(args):
|
|
512
|
+
"""`lane gc --sockets` (plan Task 18), explicit and in the container only — the host merely reports (`lanes
|
|
513
|
+
doctor`; the RUNBOOK NEVER rule). For each SOCKET file in tmux's directory one `tmux -N -S <path> list-sessions`
|
|
514
|
+
(-N: never start a server; no lane identity in its env): only a file tmux is REFUSED on (ECONNREFUSED — nobody
|
|
515
|
+
listens, its server is gone) is unlinked, and only if it is still the very file that was probed (a new server may
|
|
516
|
+
have taken the path meanwhile). A server that answers — even with no session — or that does not answer in time
|
|
517
|
+
keeps its file: a busy server is not a dead one. Prints each unlinked path.
|
|
518
|
+
|
|
519
|
+
Refusal is read from the errno, never from tmux's prose: tmux 3.4 words ECONNREFUSED as `no server running on
|
|
520
|
+
<path>` and uses `error connecting to <path> (<strerror>)` only for OTHER errnos (measured 2026-10-04: EACCES →
|
|
521
|
+
`(Permission denied)`, ENOENT → `(No such file or directory)`), so a 'connection refused' text match never fired
|
|
522
|
+
(DEF-1459). When tmux fails, `socket_refused` connects to the same path once and only ConnectionRefusedError
|
|
523
|
+
counts."""
|
|
524
|
+
if args != ['--sockets']:
|
|
525
|
+
sys.exit(GC_USAGE)
|
|
526
|
+
directory = tmux_socket_dir()
|
|
527
|
+
try:
|
|
528
|
+
entries = sorted(directory.iterdir())
|
|
529
|
+
except OSError as error:
|
|
530
|
+
print(f'lane gc --sockets: {directory}: {error.strerror or error} — nothing to do')
|
|
531
|
+
return
|
|
532
|
+
removed = kept = 0
|
|
533
|
+
for path in entries:
|
|
534
|
+
try:
|
|
535
|
+
before = path.lstat()
|
|
536
|
+
except OSError:
|
|
537
|
+
continue
|
|
538
|
+
if not stat.S_ISSOCK(before.st_mode):
|
|
539
|
+
continue
|
|
540
|
+
try:
|
|
541
|
+
result = subprocess.run(['tmux', '-N', '-S', str(path), 'list-sessions'], capture_output=True, text=True,
|
|
542
|
+
timeout=GC_PROBE_TIMEOUT, env=identity_free(os.environ))
|
|
543
|
+
except subprocess.TimeoutExpired:
|
|
544
|
+
print(f'kept {path}: no answer in {GC_PROBE_TIMEOUT} s')
|
|
545
|
+
kept += 1
|
|
546
|
+
continue
|
|
547
|
+
if result.returncode == 0 or not socket_refused(path):
|
|
548
|
+
kept += 1
|
|
549
|
+
continue
|
|
550
|
+
try:
|
|
551
|
+
after = path.lstat()
|
|
552
|
+
except OSError:
|
|
553
|
+
continue # gone meanwhile: nothing to remove
|
|
554
|
+
if (after.st_dev, after.st_ino, after.st_ctime_ns) != (before.st_dev, before.st_ino, before.st_ctime_ns):
|
|
555
|
+
kept += 1 # another server bound the path after the probe: not the file tmux was refused on
|
|
556
|
+
continue
|
|
557
|
+
path.unlink()
|
|
558
|
+
print(f'unlinked {path}')
|
|
559
|
+
removed += 1
|
|
560
|
+
print(f'lane gc --sockets: {removed} unlinked, {kept} kept in {directory}')
|
|
561
|
+
|
|
562
|
+
|
|
279
563
|
COMMANDS = {'new': cmd_new, 'open': cmd_open, 'attach': cmd_attach, 'send': cmd_send, 'capture': cmd_capture, 'ls': cmd_ls,
|
|
280
|
-
'status': cmd_status, 'kill': cmd_kill}
|
|
564
|
+
'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc}
|
|
281
565
|
|
|
282
566
|
if __name__ == '__main__':
|
|
283
567
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|