ftown 0.25.0 → 0.25.1
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/dist/agent-commands.d.ts +4 -2
- package/dist/agent-commands.js +1 -1
- package/dist/agent-commands.js.map +1 -1
- package/dist/antigravity-hook-installer.d.ts +26 -0
- package/dist/antigravity-hook-installer.js +225 -0
- package/dist/antigravity-hook-installer.js.map +1 -0
- package/dist/ask-question-hook.d.ts +11 -0
- package/dist/ask-question-hook.js +37 -1
- package/dist/ask-question-hook.js.map +1 -1
- package/dist/command-rpc.js +2 -0
- package/dist/command-rpc.js.map +1 -1
- package/dist/create-ftown-session.d.ts +4 -2
- package/dist/create-ftown-session.js +13 -0
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/grok-hook-installer.d.ts +10 -0
- package/dist/grok-hook-installer.js +87 -0
- package/dist/grok-hook-installer.js.map +1 -0
- package/dist/harness-commands.js +4 -1
- package/dist/harness-commands.js.map +1 -1
- package/dist/harness-registry.d.ts +17 -2
- package/dist/harness-registry.js +43 -1
- package/dist/harness-registry.js.map +1 -1
- package/dist/local-api-server.d.ts +1 -1
- package/dist/local-api-server.js +7 -1
- package/dist/local-api-server.js.map +1 -1
- package/dist/session-ids.d.ts +3 -3
- package/dist/session-ids.js +50 -34
- package/dist/session-ids.js.map +1 -1
- package/dist/setup.js +6 -0
- package/dist/setup.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/hooks/ftown-antigravity-hook.sh +169 -0
- package/hooks/ftown-grok-hook.sh +157 -0
- package/hooks/notify.sh +8 -0
- package/package.json +2 -2
- package/skills/ftown/references/sessions.md +11 -3
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ftown hook for the Google Antigravity CLI (agy); installed to
|
|
3
|
+
# ~/.ftown/antigravity-hook.sh and registered in ~/.gemini/config/hooks.json.
|
|
4
|
+
# Translates agy's camelCase hook payload (stdin) to the bridge /hook wire format and POSTs it.
|
|
5
|
+
# On a turn-end Stop it drains this session's ftown inbox FIRST: with mail it
|
|
6
|
+
# prints {"decision":"continue","reason":"<mail>"} and posts nothing (the agent
|
|
7
|
+
# keeps working); without mail it posts Stop. On terminationReason
|
|
8
|
+
# MAX_FORCED_INVOCATIONS / USER_CANCELED / ERROR it never drains, only posts Stop.
|
|
9
|
+
#
|
|
10
|
+
# Always exits 0, also on HUP/INT/TERM/PIPE (a non-zero or signalled PreToolUse
|
|
11
|
+
# hook makes agy BLOCK the tool), and writes nothing to stdout except that one
|
|
12
|
+
# Stop mail line (agy parses stdout as JSON). Inert unless FTOWN_SESSION_ID is
|
|
13
|
+
# set and curl + jq are available.
|
|
14
|
+
#
|
|
15
|
+
# Worst-case runtime: ~6s inside agy's 10s hook timeout. The POST tries at most
|
|
16
|
+
# 2 endpoint candidates x curl --max-time 2 (4s); on Stop the inbox read through
|
|
17
|
+
# `ftown security proxy` uses a 2s request timeout and happens before the POST,
|
|
18
|
+
# and when it delivers mail no POST follows.
|
|
19
|
+
#
|
|
20
|
+
# bash 3.2 compatible (macOS /bin/bash). No set -e on purpose; no temp files.
|
|
21
|
+
# The hook's working directory is agy's hooks.json folder, so pwd is never used.
|
|
22
|
+
|
|
23
|
+
BG_PID=""
|
|
24
|
+
# shellcheck disable=SC2329 # invoked through the trap below
|
|
25
|
+
on_signal() {
|
|
26
|
+
[ -n "$BG_PID" ] && kill "$BG_PID" 2>/dev/null
|
|
27
|
+
exit 0
|
|
28
|
+
}
|
|
29
|
+
trap 'exit 0' EXIT
|
|
30
|
+
trap on_signal HUP INT TERM PIPE
|
|
31
|
+
|
|
32
|
+
INPUT=$(cat)
|
|
33
|
+
|
|
34
|
+
[ -n "${FTOWN_SESSION_ID:-}" ] || exit 0
|
|
35
|
+
command -v curl >/dev/null 2>&1 || exit 0
|
|
36
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
37
|
+
|
|
38
|
+
# Parse once; invalid JSON or a missing hookEventName ends the hook silently.
|
|
39
|
+
FIELDS=$(printf '%s' "$INPUT" | jq -c '
|
|
40
|
+
if type == "object" and ((.hookEventName // "") | type) == "string" and (.hookEventName // "") != "" then
|
|
41
|
+
{event: .hookEventName,
|
|
42
|
+
first: (.invocationNum == 0),
|
|
43
|
+
term: (.terminationReason | if type == "string" then . else "" end),
|
|
44
|
+
conv: ((.conversationId // "") | if type == "string" then . else tostring end),
|
|
45
|
+
cwd: ((.cwd // "") | if type == "string" then . else tostring end),
|
|
46
|
+
tool: (if (.toolCall | type) == "object" then
|
|
47
|
+
({} + (if .toolCall.name != null then {tool_name: .toolCall.name} else {} end)
|
|
48
|
+
+ (if (.toolCall.args | type) == "object" then {tool_input: .toolCall.args} else {} end)
|
|
49
|
+
+ (if (.toolCall.id | type) == "string" then {tool_use_id: .toolCall.id} else {} end))
|
|
50
|
+
else {} end)}
|
|
51
|
+
else empty end' 2>/dev/null) || exit 0
|
|
52
|
+
INPUT=""
|
|
53
|
+
[ -n "$FIELDS" ] || exit 0
|
|
54
|
+
|
|
55
|
+
EVENT=$(printf '%s' "$FIELDS" | jq -r '.event' 2>/dev/null) || exit 0
|
|
56
|
+
case "$EVENT" in
|
|
57
|
+
SessionStart|PreToolUse|PostToolUse|Stop) HOOK_EVENT="$EVENT" ;;
|
|
58
|
+
PreInvocation)
|
|
59
|
+
FIRST=$(printf '%s' "$FIELDS" | jq -r '.first' 2>/dev/null) || exit 0
|
|
60
|
+
[ "$FIRST" = "true" ] || exit 0
|
|
61
|
+
HOOK_EVENT="UserPromptSubmit"
|
|
62
|
+
;;
|
|
63
|
+
*) exit 0 ;;
|
|
64
|
+
esac
|
|
65
|
+
|
|
66
|
+
# Endpoint candidates, mirroring the muse helper: explicit env first, then the
|
|
67
|
+
# bridge pointer the current bridge rewrites on every startup.
|
|
68
|
+
CANDIDATES=""
|
|
69
|
+
add_candidate() {
|
|
70
|
+
case "$1" in ''|*[!0-9]*) return 0 ;; esac
|
|
71
|
+
[ "$1" -gt 0 ] 2>/dev/null || return 0
|
|
72
|
+
CANDIDATES="${CANDIDATES}$1 $2
|
|
73
|
+
"
|
|
74
|
+
}
|
|
75
|
+
ENV_PORT="${FTOWN_HOOK_PORT:-}"
|
|
76
|
+
ENV_TOKEN="${FTOWN_HOOK_TOKEN:-}"
|
|
77
|
+
add_candidate "$ENV_PORT" "$ENV_TOKEN"
|
|
78
|
+
BRIDGE_JSON="${FTOWN_BRIDGE_POINTER:-${HOME:-}/.ftown/bridge.json}"
|
|
79
|
+
if [ -n "${HOME:-}" ] && [ -f "$BRIDGE_JSON" ]; then
|
|
80
|
+
BPORT=$(jq -r '.port // empty' "$BRIDGE_JSON" 2>/dev/null)
|
|
81
|
+
BTOKEN=$(jq -r '.token // empty' "$BRIDGE_JSON" 2>/dev/null)
|
|
82
|
+
if [ -n "$BPORT" ] && { [ "$BPORT" != "$ENV_PORT" ] || [ "$BTOKEN" != "$ENV_TOKEN" ]; }; then
|
|
83
|
+
add_candidate "$BPORT" "$BTOKEN"
|
|
84
|
+
fi
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# One POST attempt, run in the background so a trapped signal interrupts the
|
|
88
|
+
# `wait` at once instead of after curl's timeout. Its stdout/stderr go to
|
|
89
|
+
# /dev/null so a curl left behind by a signal (it ends within its own 2s
|
|
90
|
+
# bound) never holds agy's hook pipes open. The body goes on curl's
|
|
91
|
+
# stdin (no ARG_MAX limit); the attempt succeeds only on a 2xx status.
|
|
92
|
+
post_once() {
|
|
93
|
+
if [ -n "$3" ]; then
|
|
94
|
+
printf '%s' "$1" | curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$2/hook" \
|
|
95
|
+
-H 'Content-Type: application/json' -H "Authorization: Bearer $3" \
|
|
96
|
+
--connect-timeout 1 --max-time 2 --data-binary @- 2>/dev/null
|
|
97
|
+
else
|
|
98
|
+
printf '%s' "$1" | curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$2/hook" \
|
|
99
|
+
-H 'Content-Type: application/json' \
|
|
100
|
+
--connect-timeout 1 --max-time 2 --data-binary @- 2>/dev/null
|
|
101
|
+
fi | { IFS= read -r _code; case "${_code:-}" in 2*) exit 0 ;; esac; exit 1; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# POST the hook payload; first 2xx wins. Fire-and-forget: failures are silent.
|
|
105
|
+
post_hook() {
|
|
106
|
+
_body="$1"
|
|
107
|
+
while IFS=' ' read -r _port _token _rest; do
|
|
108
|
+
[ -n "${_port:-}" ] || continue
|
|
109
|
+
post_once "$_body" "$_port" "${_token:-}" >/dev/null 2>&1 &
|
|
110
|
+
BG_PID=$!
|
|
111
|
+
wait "$BG_PID"
|
|
112
|
+
_rc=$?
|
|
113
|
+
BG_PID=""
|
|
114
|
+
[ "$_rc" -eq 0 ] && return 0
|
|
115
|
+
done <<CANDIDATES_EOF
|
|
116
|
+
$CANDIDATES
|
|
117
|
+
CANDIDATES_EOF
|
|
118
|
+
return 1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
PAYLOAD=$(printf '%s' "$FIELDS" | jq -c \
|
|
122
|
+
--arg sid "$FTOWN_SESSION_ID" \
|
|
123
|
+
--arg ev "$HOOK_EVENT" \
|
|
124
|
+
--arg envconv "${ANTIGRAVITY_CONVERSATION_ID:-}" \
|
|
125
|
+
'{ftown_session_id: $sid, ftown_session_source: "env", hook_event_name: $ev,
|
|
126
|
+
session_id: (if .conv != "" then .conv else $envconv end), cwd: .cwd}
|
|
127
|
+
+ (if $ev == "PreToolUse" or $ev == "PostToolUse" then .tool else {} end)' \
|
|
128
|
+
2>/dev/null) || exit 0
|
|
129
|
+
[ -n "$PAYLOAD" ] || exit 0
|
|
130
|
+
|
|
131
|
+
if [ "$HOOK_EVENT" != "Stop" ]; then
|
|
132
|
+
post_hook "$PAYLOAD"
|
|
133
|
+
exit 0
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# Stop: drain the inbox first (unless agy will not continue), exactly like the
|
|
137
|
+
# muse helper: a single wait=0 drain through the authenticated
|
|
138
|
+
# `ftown security proxy`, which marks the mail delivered.
|
|
139
|
+
drain_mail() {
|
|
140
|
+
_term=$(printf '%s' "$FIELDS" | jq -r '.term' 2>/dev/null)
|
|
141
|
+
case "$_term" in
|
|
142
|
+
MAX_FORCED_INVOCATIONS|USER_CANCELED|ERROR) return 1 ;;
|
|
143
|
+
esac
|
|
144
|
+
_enc=$(jq -n -r --arg s "$FTOWN_SESSION_ID" '$s | @uri' 2>/dev/null)
|
|
145
|
+
[ -n "$_enc" ] || return 1
|
|
146
|
+
_cli=$(command -v ftown 2>/dev/null || true)
|
|
147
|
+
[ -n "$_cli" ] || _cli="${HOME:-}/.ftown/bin/ftown"
|
|
148
|
+
[ -x "$_cli" ] || return 1
|
|
149
|
+
_req=$(jq -n -c --arg path "/api/sessions/${_enc}/inbox?wait=0" '{method:"GET",path:$path,timeoutMs:2000}' 2>/dev/null)
|
|
150
|
+
[ -n "$_req" ] || return 1
|
|
151
|
+
_inbox=$(printf '%s' "$_req" | "$_cli" security proxy 2>/dev/null | jq -c 'select(.status >= 200 and .status < 300) | .body' 2>/dev/null)
|
|
152
|
+
[ -n "$_inbox" ] || return 1
|
|
153
|
+
_count=$(printf '%s' "$_inbox" | jq -r '(.messages // []) | length' 2>/dev/null)
|
|
154
|
+
[ -n "$_count" ] && [ "$_count" -gt 0 ] 2>/dev/null || return 1
|
|
155
|
+
MAIL_OUT=$(printf '%s' "$_inbox" | jq -c \
|
|
156
|
+
'{decision: "continue", reason: ("[ftown mail]\n" + ([(.messages // [])[] | "[\(.ts // "")] \(.fromName // .from // "external") (\(.type // "message")): \(.body // "")"] | join("\n")))}' \
|
|
157
|
+
2>/dev/null)
|
|
158
|
+
[ -n "$MAIL_OUT" ]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
MAIL_OUT=""
|
|
162
|
+
if drain_mail; then
|
|
163
|
+
# Mail delivered: the agent continues, so the session stays busy (no POST).
|
|
164
|
+
# stderr is silenced so a reader that closed the pipe cannot see "Broken pipe".
|
|
165
|
+
printf '%s\n' "$MAIL_OUT" 2>/dev/null
|
|
166
|
+
exit 0
|
|
167
|
+
fi
|
|
168
|
+
post_hook "$PAYLOAD"
|
|
169
|
+
exit 0
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ftown hook for the Grok CLI, installed to ~/.ftown/grok-hook.sh and
|
|
3
|
+
# registered for every grok hook event in ~/.grok/hooks/ftown.json.
|
|
4
|
+
# What it does:
|
|
5
|
+
# 1. Reads grok's hook payload (JSON on stdin) and POSTs it to the bridge
|
|
6
|
+
# /hook route in ftown's snake_case wire format.
|
|
7
|
+
# 2. On Stop with reason "end_turn" it drains this session's ftown inbox
|
|
8
|
+
# FIRST: when mail was delivered it prints
|
|
9
|
+
# {"decision":"block","reason":"<mail>"} so grok continues the turn with
|
|
10
|
+
# the mail (capped at 9,500 characters plus a re-read pointer, since grok
|
|
11
|
+
# clips the reason at 10,000) and posts NOTHING, keeping the session busy;
|
|
12
|
+
# with no mail it posts Stop. Stop "shutdown"/"channel_closed" (session
|
|
13
|
+
# end) is ignored entirely; any other Stop reason just posts Stop.
|
|
14
|
+
#
|
|
15
|
+
# Inert (exit 0, no output) unless spawned by ftown (FTOWN_SESSION_ID set) and
|
|
16
|
+
# curl + jq are available. Always exits 0; stdout is written only for mail.
|
|
17
|
+
#
|
|
18
|
+
# Worst-case runtime (grok hook timeout is 10s): on Stop end_turn one inbox
|
|
19
|
+
# read through `ftown security proxy` with timeoutMs 2000 (2s plus CLI
|
|
20
|
+
# startup), then, only when no mail came back, the POST tries at most two
|
|
21
|
+
# endpoints (env port, bridge pointer), each curl bounded by --max-time 2
|
|
22
|
+
# (4s): about 6s plus CLI startup.
|
|
23
|
+
# Bridge reachability, auth, inbox read and mail text mirror
|
|
24
|
+
# bridge/muse-plugin/lib/ftown-hook.sh.
|
|
25
|
+
|
|
26
|
+
# Consume the whole payload first so grok never sees a broken pipe even when
|
|
27
|
+
# the hook turns out to be inert.
|
|
28
|
+
INPUT=$(cat 2>/dev/null)
|
|
29
|
+
|
|
30
|
+
# Inert unless spawned by ftown.
|
|
31
|
+
[ -n "${FTOWN_SESSION_ID:-}" ] || exit 0
|
|
32
|
+
command -v curl >/dev/null 2>&1 || exit 0
|
|
33
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
34
|
+
|
|
35
|
+
# Valid JSON object with a non-empty string hook_event_name, else silent exit.
|
|
36
|
+
printf '%s' "$INPUT" | jq -e \
|
|
37
|
+
'type == "object" and (.hook_event_name | type == "string" and length > 0)' \
|
|
38
|
+
>/dev/null 2>&1 || exit 0
|
|
39
|
+
|
|
40
|
+
HOOK_EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name' 2>/dev/null) || exit 0
|
|
41
|
+
STOP_REASON=$(printf '%s' "$INPUT" | jq -r '.reason // empty | tostring' 2>/dev/null)
|
|
42
|
+
[ -n "$HOOK_EVENT" ] || exit 0
|
|
43
|
+
|
|
44
|
+
# Grok's two session-end reasons (every process exit fires an extra Stop with
|
|
45
|
+
# one of them) are not turn ends: no POST, no inbox read, no output.
|
|
46
|
+
if [ "$HOOK_EVENT" = "Stop" ]; then
|
|
47
|
+
case "$STOP_REASON" in
|
|
48
|
+
shutdown|channel_closed) exit 0 ;;
|
|
49
|
+
esac
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# Endpoint candidates, mirroring the muse helper: explicit env first, then the
|
|
53
|
+
# bridge pointer the current bridge rewrites on every startup (a resurrected
|
|
54
|
+
# session may hold a stale env port).
|
|
55
|
+
CANDIDATES=""
|
|
56
|
+
add_candidate() {
|
|
57
|
+
case "$1" in ''|*[!0-9]*) return 0 ;; esac
|
|
58
|
+
[ "$1" -gt 0 ] 2>/dev/null || return 0
|
|
59
|
+
CANDIDATES="${CANDIDATES}$1 $2
|
|
60
|
+
"
|
|
61
|
+
}
|
|
62
|
+
ENV_PORT="${FTOWN_HOOK_PORT:-}"
|
|
63
|
+
ENV_TOKEN="${FTOWN_HOOK_TOKEN:-}"
|
|
64
|
+
add_candidate "$ENV_PORT" "$ENV_TOKEN"
|
|
65
|
+
BRIDGE_JSON="${FTOWN_BRIDGE_POINTER:-${HOME:-}/.ftown/bridge.json}"
|
|
66
|
+
if [ -n "${HOME:-}" ] && [ -f "$BRIDGE_JSON" ]; then
|
|
67
|
+
BPORT=$(jq -r '.port // empty' "$BRIDGE_JSON" 2>/dev/null)
|
|
68
|
+
BTOKEN=$(jq -r '.token // empty' "$BRIDGE_JSON" 2>/dev/null)
|
|
69
|
+
if [ -n "$BPORT" ] && { [ "$BPORT" != "$ENV_PORT" ] || [ "$BTOKEN" != "$ENV_TOKEN" ]; }; then
|
|
70
|
+
add_candidate "$BPORT" "$BTOKEN"
|
|
71
|
+
fi
|
|
72
|
+
fi
|
|
73
|
+
[ -n "$CANDIDATES" ] || exit 0
|
|
74
|
+
|
|
75
|
+
# POST the hook payload; first 2xx wins. Fire-and-forget: failures are silent.
|
|
76
|
+
# The body goes on curl's stdin (printf is a builtin), never argv, so a large
|
|
77
|
+
# tool_input cannot exceed ARG_MAX.
|
|
78
|
+
post_hook() {
|
|
79
|
+
_body="$1"
|
|
80
|
+
while IFS=' ' read -r _port _token _rest; do
|
|
81
|
+
[ -n "${_port:-}" ] || continue
|
|
82
|
+
if [ -n "${_token:-}" ]; then
|
|
83
|
+
_code=$(printf '%s' "$_body" | curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:${_port}/hook" \
|
|
84
|
+
-H 'Content-Type: application/json' -H "Authorization: Bearer ${_token}" \
|
|
85
|
+
--connect-timeout 1 --max-time 2 --data-binary @- 2>/dev/null)
|
|
86
|
+
else
|
|
87
|
+
_code=$(printf '%s' "$_body" | curl -s -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:${_port}/hook" \
|
|
88
|
+
-H 'Content-Type: application/json' \
|
|
89
|
+
--connect-timeout 1 --max-time 2 --data-binary @- 2>/dev/null)
|
|
90
|
+
fi
|
|
91
|
+
case "${_code:-}" in
|
|
92
|
+
2*) return 0 ;;
|
|
93
|
+
esac
|
|
94
|
+
done <<CANDIDATES_EOF
|
|
95
|
+
$CANDIDATES
|
|
96
|
+
CANDIDATES_EOF
|
|
97
|
+
return 1
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Build the wire body with jq straight from the payload. tool_* keys are kept
|
|
101
|
+
# only when present (tool_input stays a JSON value); session_id falls back to
|
|
102
|
+
# $GROK_SESSION_ID when the payload has none.
|
|
103
|
+
PAYLOAD=$(printf '%s' "$INPUT" | jq -c \
|
|
104
|
+
--arg sid "$FTOWN_SESSION_ID" \
|
|
105
|
+
--arg gsid "${GROK_SESSION_ID:-}" \
|
|
106
|
+
'{
|
|
107
|
+
ftown_session_id: $sid,
|
|
108
|
+
ftown_session_source: "env",
|
|
109
|
+
hook_event_name: .hook_event_name,
|
|
110
|
+
session_id: (if (.session_id | type) == "string" and .session_id != "" then .session_id else $gsid end),
|
|
111
|
+
cwd: (if (.cwd | type) == "string" then .cwd else "" end)
|
|
112
|
+
}
|
|
113
|
+
+ (if .tool_name != null then {tool_name: .tool_name} else {} end)
|
|
114
|
+
+ (if .tool_input != null then {tool_input: .tool_input} else {} end)
|
|
115
|
+
+ (if .tool_use_id != null then {tool_use_id: .tool_use_id} else {} end)' \
|
|
116
|
+
2>/dev/null) || exit 0
|
|
117
|
+
[ -n "$PAYLOAD" ] || exit 0
|
|
118
|
+
|
|
119
|
+
# Drain this session's inbox: single wait=0 read through `ftown security proxy`
|
|
120
|
+
# (marks delivered). On success sets MAIL_DECISION to the Stop "block" line
|
|
121
|
+
# and returns 0; returns 1 when no mail was delivered for any reason (empty
|
|
122
|
+
# inbox, CLI missing, proxy failure).
|
|
123
|
+
MAIL_DECISION=""
|
|
124
|
+
drain_mail() {
|
|
125
|
+
_sid_enc=$(jq -n -r --arg s "$FTOWN_SESSION_ID" '$s | @uri' 2>/dev/null)
|
|
126
|
+
[ -n "$_sid_enc" ] || return 1
|
|
127
|
+
_cli=$(command -v ftown 2>/dev/null || true)
|
|
128
|
+
[ -n "$_cli" ] || _cli="${HOME:-}/.ftown/bin/ftown"
|
|
129
|
+
[ -x "$_cli" ] || return 1
|
|
130
|
+
_request=$(jq -n -c --arg path "/api/sessions/${_sid_enc}/inbox?wait=0" '{method:"GET",path:$path,timeoutMs:2000}' 2>/dev/null) || return 1
|
|
131
|
+
_inbox=$(printf '%s' "$_request" | "$_cli" security proxy 2>/dev/null | jq -c 'select(.status >= 200 and .status < 300) | .body' 2>/dev/null)
|
|
132
|
+
[ -n "$_inbox" ] || return 1
|
|
133
|
+
_count=$(printf '%s' "$_inbox" | jq -r '(.messages // []) | length' 2>/dev/null)
|
|
134
|
+
[ -n "$_count" ] && [ "$_count" -gt 0 ] 2>/dev/null || return 1
|
|
135
|
+
# Grok clips a block reason at 10,000 characters, and the drain has already
|
|
136
|
+
# marked the mail delivered: cap the text at 9,500 characters (jq counts
|
|
137
|
+
# codepoints, not bytes) and, when cut, end with a re-read pointer.
|
|
138
|
+
MAIL_DECISION=$(printf '%s' "$_inbox" | jq -c \
|
|
139
|
+
--arg pointer '[ftown mail truncated: run `ftown sessions inbox --all --peek` to read the full messages]' \
|
|
140
|
+
'("[ftown mail]\n" + ([(.messages // [])[] | "[\(.ts // "")] \(.fromName // .from // "external") (\(.type // "message")): \(.body // "")"] | join("\n")))
|
|
141
|
+
| (if length > 9500 then .[0:9500] + "\n" + $pointer else . end)
|
|
142
|
+
| {decision: "block", reason: .}' \
|
|
143
|
+
2>/dev/null) || return 1
|
|
144
|
+
[ -n "$MAIL_DECISION" ]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Turn-end ordering (contract A2.1): on a real turn end drain FIRST. When mail
|
|
148
|
+
# was delivered grok keeps working, so print the decision and post nothing
|
|
149
|
+
# (posting Stop would mark a busy session idle). Otherwise post Stop.
|
|
150
|
+
if [ "$HOOK_EVENT" = "Stop" ] && [ "$STOP_REASON" = "end_turn" ]; then
|
|
151
|
+
if drain_mail; then
|
|
152
|
+
printf '%s\n' "$MAIL_DECISION"
|
|
153
|
+
exit 0
|
|
154
|
+
fi
|
|
155
|
+
fi
|
|
156
|
+
post_hook "$PAYLOAD"
|
|
157
|
+
exit 0
|
package/hooks/notify.sh
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
INPUT=$(cat)
|
|
3
|
+
# Grok runs Claude-compatible hooks too; ~/.ftown/grok-hook.sh is its channel. Stand down only
|
|
4
|
+
# when GROK_HOOK_EVENT is set and is not "notification" (the Grok script does not forward that
|
|
5
|
+
# event) AND the payload has a string hookEventName (Grok always sends it; Claude, Codex and
|
|
6
|
+
# Cursor do not, so an inherited variable does not silence them).
|
|
7
|
+
if [ -n "${GROK_HOOK_EVENT:-}" ] && [ "${GROK_HOOK_EVENT}" != "notification" ] \
|
|
8
|
+
&& printf '%s' "$INPUT" | jq -e '(.hookEventName | type) == "string" and (.hookEventName | length) > 0' >/dev/null 2>&1; then
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
3
11
|
|
|
4
12
|
PORT="${FTOWN_HOOK_PORT:-}"
|
|
5
13
|
SESSION_ID="${FTOWN_SESSION_ID:-}"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ftown",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -72,5 +72,5 @@
|
|
|
72
72
|
"overrides": {
|
|
73
73
|
"qs": "6.16.1"
|
|
74
74
|
},
|
|
75
|
-
"gitHead": "
|
|
75
|
+
"gitHead": "d7098c8cbdbce5b92542cf883a6c2b5900be2276"
|
|
76
76
|
}
|
|
@@ -46,7 +46,7 @@ ftown sessions remove <session-id>
|
|
|
46
46
|
ftown sessions archive
|
|
47
47
|
|
|
48
48
|
# Recreate a removed session from its tombstone (resumes the agent
|
|
49
|
-
# conversation when a claude/cursor/codex/Pi session id was recorded; Kimi Code
|
|
49
|
+
# conversation when a claude/cursor/codex/Pi/grok/antigravity session id was recorded; Kimi Code
|
|
50
50
|
# continues by working directory; the revived session gets a NEW id)
|
|
51
51
|
ftown sessions revive <session-id>
|
|
52
52
|
```
|
|
@@ -205,6 +205,14 @@ follow-up turn when mail arrives, including while Pi is idle. opencode's
|
|
|
205
205
|
installed plugin delivers pending mail as a new prompt when a turn goes idle.
|
|
206
206
|
Muse's installed `ftown` plugin reports the native session id on SessionStart
|
|
207
207
|
and pumps pending mail as context on Stop.
|
|
208
|
+
Grok's ftown hooks (`~/.grok/hooks/ftown.json`) report the native session id
|
|
209
|
+
and deliver mail that arrives mid-turn when the turn ends; the typed nudge
|
|
210
|
+
remains the fallback for an idle Grok agent.
|
|
211
|
+
Antigravity's ftown Stop hook (`~/.gemini/config/hooks.json`, entry `ftown`)
|
|
212
|
+
reports the conversation id and delivers mail at turn end.
|
|
213
|
+
Grok and Antigravity hooks do not run in the interactive TUI until the folder
|
|
214
|
+
has been trusted in that CLI's own trust prompt; the owner answers it once per
|
|
215
|
+
folder, and ftown does not bypass it.
|
|
208
216
|
Cursor and shell sessions rely on an idle one-line nudge to run `ftown harness
|
|
209
217
|
mail read` instead.
|
|
210
218
|
|
|
@@ -235,8 +243,8 @@ Fan-out targets are messaged sequentially, one JSON result line per target.
|
|
|
235
243
|
|
|
236
244
|
| Flag | Description |
|
|
237
245
|
|------|-------------|
|
|
238
|
-
| `--shell` | Explicit harness override: `claude`, `cursor`, `codex`, `grok`, `muse`, `pi`, `kimi-code`, `opencode`, `shell`, or Claude Code provider flavors `zai`, `kimi`, `deepseek`, `fireworks`. When omitted inside a session, inherits that session's harness; outside a session, defaults to `claude`. |
|
|
239
|
-
| `--prompt` | Initial task — passed as a CLI launch argument to `claude`/Claude provider flavors/`cursor`/`codex`/`grok`/`muse`/`pi` (typed after boot for other shells) |
|
|
246
|
+
| `--shell` | Explicit harness override: `claude`, `cursor`, `codex`, `grok`, `muse`, `antigravity`, `pi`, `kimi-code`, `opencode`, `shell`, or Claude Code provider flavors `zai`, `kimi`, `deepseek`, `fireworks`. When omitted inside a session, inherits that session's harness; outside a session, defaults to `claude`. |
|
|
247
|
+
| `--prompt` | Initial task — passed as a CLI launch argument to `claude`/Claude provider flavors/`cursor`/`codex`/`grok`/`muse`/`antigravity`/`pi` (typed after boot for other shells) |
|
|
240
248
|
| `--workdir` | Working directory |
|
|
241
249
|
| `--name` | Dashboard label |
|
|
242
250
|
| `--command` | Full command override (skips `--shell` builder) |
|