@sayansr26/agent-os 0.6.1 → 0.6.3
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/.claude-plugin/marketplace.json +13 -1
- package/CHANGELOG.md +44 -0
- package/README.md +36 -2
- package/cursor/hooks/git-guard.mjs +40 -0
- package/cursor/skills/agent-os-init/SKILL.md +116 -0
- package/package.json +4 -4
- package/plugins/agent-os/.claude-plugin/plugin.json +3 -3
- package/plugins/agent-os/hooks/pre-edit.mjs +36 -7
- package/plugins/agent-os/hooks/session-resume.mjs +29 -15
- package/plugins/auto-handoff/.claude-plugin/plugin.json +49 -0
- package/plugins/auto-handoff/LICENSE +21 -0
- package/plugins/auto-handoff/README.md +131 -0
- package/plugins/auto-handoff/UPSTREAM.md +22 -0
- package/plugins/auto-handoff/hooks/brief.ts +190 -0
- package/plugins/auto-handoff/hooks/config.ts +64 -0
- package/plugins/auto-handoff/hooks/hooks.json +1 -0
- package/plugins/auto-handoff/hooks/panel.tsx +45 -0
- package/plugins/auto-handoff/hooks/register.tsx +615 -0
- package/plugins/auto-handoff/hooks/server.ts +32 -0
- package/plugins/auto-handoff/hooks/templates.ts +17 -0
- package/plugins/auto-handoff/hooks/viewer.ts +173 -0
- package/plugins/auto-handoff/statusline/handoff-link.sh +15 -0
- package/plugins/auto-handoff/templates/brief.md +34 -0
- package/plugins/auto-handoff/templates/instructions.md +10 -0
- package/plugins/auto-handoff/tests/auto-handoff.test.tsx +931 -0
- package/plugins/auto-handoff/tests/seed-row.test.tsx +58 -0
- package/plugins/auto-handoff/tests/viewer.test.ts +111 -0
- package/plugins/auto-handoff/tsconfig.json +4 -0
- package/src/cli.mjs +57 -12
- package/src/cursor.mjs +167 -0
- package/src/plugin.mjs +5 -4
- package/src/selftest.mjs +65 -1
- package/src/targets.mjs +18 -11
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The handoff viewer: one self-contained HTML page per brief, written to a pages/ folder beside
|
|
2
|
+
// the briefs. Each brief starts with a small header (from, to, chain, tokens, at, cwd); briefs that
|
|
3
|
+
// share a chain id or a from/to link are one run of handoffs, and every page in a run lists all of them.
|
|
4
|
+
// The page renders the brief's markdown in the browser from a CDN, so the mod ships no packages.
|
|
5
|
+
|
|
6
|
+
export type Header = { from?: string; to?: string; chain?: string; depth?: string; tokens?: string; at?: string; cwd?: string; viewer?: string }
|
|
7
|
+
export type Entry = { id: string; header: Header; body: string }
|
|
8
|
+
|
|
9
|
+
const HEADER_KEYS = ['from', 'to', 'chain', 'depth', 'tokens', 'at', 'cwd', 'viewer'] as const
|
|
10
|
+
|
|
11
|
+
/** Splits a brief into its header and body. A brief with no header is a chain of one. */
|
|
12
|
+
export function parseBrief(text: string): { header: Header; body: string } {
|
|
13
|
+
const m = /^---\n([\s\S]*?)\n---\n?/.exec(text)
|
|
14
|
+
if (!m) return { header: {}, body: text }
|
|
15
|
+
const header: Header = {}
|
|
16
|
+
for (const line of m[1]!.split('\n')) {
|
|
17
|
+
const kv = /^(\w+):\s*(.*)$/.exec(line)
|
|
18
|
+
if (kv && (HEADER_KEYS as readonly string[]).includes(kv[1]!)) header[kv[1] as keyof Header] = kv[2]!.trim()
|
|
19
|
+
}
|
|
20
|
+
return { header, body: text.slice(m[0].length) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function withHeader(header: Header, body: string): string {
|
|
24
|
+
const lines = HEADER_KEYS.filter(k => header[k] !== undefined).map(k => `${k}: ${header[k]}`)
|
|
25
|
+
return `---\n${lines.join('\n')}\n---\n${body}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every brief linked to id, oldest first. Briefs join through a shared chain id or a from/to
|
|
29
|
+
* link, so a run whose chain id broke partway (a hot reload once dropped it) still reads as one. */
|
|
30
|
+
export function chainOf(entries: readonly Entry[], id: string): Entry[] {
|
|
31
|
+
const parent = new Map<string, string>()
|
|
32
|
+
const find = (x: string): string => {
|
|
33
|
+
let r = x
|
|
34
|
+
while (parent.has(r) && parent.get(r) !== r) r = parent.get(r)!
|
|
35
|
+
parent.set(x, r)
|
|
36
|
+
return r
|
|
37
|
+
}
|
|
38
|
+
const join = (a: string, b: string) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb) }
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
join(e.id, `chain:${e.header.chain || e.id}`)
|
|
41
|
+
if (e.header.from) join(e.id, e.header.from)
|
|
42
|
+
if (e.header.to) join(e.id, e.header.to)
|
|
43
|
+
}
|
|
44
|
+
const root = find(id)
|
|
45
|
+
return entries.filter(e => find(e.id) === root).sort((a, b) => (a.header.at ?? '').localeCompare(b.header.at ?? ''))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The page's address: served when the mod runs a server, else the local file. The served link
|
|
49
|
+
* uses the session id's first 8 characters, short enough to stay on one line on a phone. */
|
|
50
|
+
export function viewerLink(serve: { host: string; port: string } | undefined, pagesDir: string, sessionId: string): string {
|
|
51
|
+
if (serve) return `http://${serve.host}:${serve.port}/${sessionId.slice(0, 8)}`
|
|
52
|
+
// A Windows path (C:\Users\x) becomes file:///C:/Users/x; a POSIX path passes through.
|
|
53
|
+
const dir = pagesDir.replace(/\\/g, '/').replace(/^(?=[A-Za-z]:)/, '/')
|
|
54
|
+
return `file://${dir}/${sessionId}.html`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Sections meant for the fresh session, not for a person reading the page.
|
|
58
|
+
const HIDDEN = new Set(['Instructions', 'How to Use This Brief'])
|
|
59
|
+
|
|
60
|
+
/** The brief's "## " sections, minus the ones written for the model. */
|
|
61
|
+
export function sections(body: string): string[] {
|
|
62
|
+
return body.split(/^(?=## )/m).map(s => s.trim()).filter(s => s && !HIDDEN.has(/^## (.+)$/m.exec(s)?.[1]?.trim() ?? ''))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function oneLiner(body: string): string {
|
|
66
|
+
const m = /## Work in Progress\s*\n+([\s\S]+?)(?:\n\n|\n##|$)/.exec(body)
|
|
67
|
+
if (!m) return ''
|
|
68
|
+
const s = m[1]!.replace(/\s+/g, ' ').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^\d+\.\s*|^-\s*/, '').trim()
|
|
69
|
+
return s.length > 120 ? `${s.slice(0, 117)}…` : s
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
|
|
73
|
+
const short = (id: string) => id.slice(0, 8)
|
|
74
|
+
const when = (iso?: string) => {
|
|
75
|
+
if (!iso) return ''
|
|
76
|
+
const d = new Date(iso)
|
|
77
|
+
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' ') + ' UTC'
|
|
78
|
+
}
|
|
79
|
+
const tokens = (t?: string) => (t && Number(t) ? ` · ${Math.round(Number(t) / 1000)}k` : '')
|
|
80
|
+
const chip = (id: string, current: boolean) => `<span class="chip${current ? ' on' : ''}" title="${esc(id)}">${esc(short(id))}</span>`
|
|
81
|
+
|
|
82
|
+
/** The page for one brief. chain is every brief in its chain, oldest first (the brief itself included). */
|
|
83
|
+
export function renderPage(entry: Entry, chain: readonly Entry[]): string {
|
|
84
|
+
const { header } = entry
|
|
85
|
+
// Previous and next are the neighbours in the chain, not the header's from/to: a header can
|
|
86
|
+
// miss a link the chain recovered. The last brief's session has no page until it hands off.
|
|
87
|
+
const at = chain.findIndex(e => e.id === entry.id)
|
|
88
|
+
const prev = chain[at - 1]
|
|
89
|
+
const next = at >= 0 ? chain[at + 1] : undefined
|
|
90
|
+
const meta = [
|
|
91
|
+
chip(entry.id, false),
|
|
92
|
+
`<span>${esc(when(header.at))}${esc(tokens(header.tokens))}</span>`,
|
|
93
|
+
header.cwd ? `<span>${esc(header.cwd)}</span>` : '',
|
|
94
|
+
prev ? `<a href="${esc(prev.id)}.html">← previous</a>` : '',
|
|
95
|
+
next ? `<a href="${esc(next.id)}.html">next →</a>`
|
|
96
|
+
: header.to ? `<span title="${esc(header.to)}">next: ${esc(short(header.to))}, still running</span>` : '',
|
|
97
|
+
].filter(Boolean).join('')
|
|
98
|
+
const rows = chain.length > 1 ? chain.map((e, i) => {
|
|
99
|
+
const current = e.id === entry.id
|
|
100
|
+
const c = current ? chip(e.id, true) : `<a href="${esc(e.id)}.html">${chip(e.id, false)}</a>`
|
|
101
|
+
const latest = i === chain.length - 1 ? '<span class="latest">latest</span>' : ''
|
|
102
|
+
const wip = oneLiner(e.body)
|
|
103
|
+
return `<div class="row"><div>${c}</div><div class="rowtext"><div><span class="when">${i + 1}. ${esc(when(e.header.at))}${esc(tokens(e.header.tokens))}</span>${latest}</div>${wip ? `<div class="wip">${esc(wip)}</div>` : ''}</div></div>`
|
|
104
|
+
}).join('') : ''
|
|
105
|
+
const history = rows ? `<div class="section"><h2>Handoff Chain</h2>${rows}</div>` : ''
|
|
106
|
+
// JSON in a script tag: escape "<" so a brief can never close the tag.
|
|
107
|
+
const data = JSON.stringify(sections(entry.body)).replace(/</g, '\\u003c')
|
|
108
|
+
return `<!DOCTYPE html>
|
|
109
|
+
<html lang="en">
|
|
110
|
+
<head>
|
|
111
|
+
<meta charset="utf-8">
|
|
112
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
113
|
+
<title>Session Context ${esc(short(entry.id))}</title>
|
|
114
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
115
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
116
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;700&family=Fraunces:opsz,wght@9..144,400;9..144,700;9..144,900&display=swap" rel="stylesheet">
|
|
117
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
|
|
118
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"></script>
|
|
119
|
+
<style>
|
|
120
|
+
:root { color-scheme: light; --paper: #F7F3ED; --ink: #1C1C1C; --red: #E63946; --blue: #457B9D; --green: #2A9D8F;
|
|
121
|
+
--font-display: 'Fraunces', serif; --font-body: 'DM Sans', sans-serif; --font-mono: 'DM Mono', monospace; }
|
|
122
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
123
|
+
body { font-family: var(--font-body); background: var(--ink); color: var(--ink); font-size: 16px; line-height: 1.7; -webkit-font-smoothing: antialiased; min-height: 100vh; }
|
|
124
|
+
.card { max-width: 760px; margin: 0 auto; padding-bottom: 48px; }
|
|
125
|
+
.header { background: var(--ink); padding: 28px 16px 24px; }
|
|
126
|
+
.header h1 { font-family: var(--font-display); font-size: 1.5rem; font-weight: 900; color: var(--paper); margin: 0 0 8px; }
|
|
127
|
+
.meta { font-family: var(--font-mono); font-size: 12px; color: rgba(247,243,237,0.5); display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; }
|
|
128
|
+
.meta a { color: rgba(247,243,237,0.75); text-decoration: none; }
|
|
129
|
+
.meta a:hover { color: var(--paper); }
|
|
130
|
+
.meta .chip { background: rgba(247,243,237,0.1); color: rgba(247,243,237,0.7); }
|
|
131
|
+
.chip { font-family: var(--font-mono); font-size: 11px; background: rgba(28,28,28,0.08); color: var(--ink); padding: 2px 7px; border-radius: 6px; white-space: nowrap; }
|
|
132
|
+
.chip.on { background: var(--ink); color: var(--paper); }
|
|
133
|
+
.section { background: var(--paper); padding: 24px 16px; border-bottom: 1px solid rgba(28,28,28,0.1); overflow-wrap: break-word; }
|
|
134
|
+
@media (min-width: 600px) { .header, .section { padding-left: 32px; padding-right: 32px; } }
|
|
135
|
+
.row { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid rgba(28,28,28,0.08); }
|
|
136
|
+
.row a { text-decoration: none; }
|
|
137
|
+
.rowtext { min-width: 0; flex: 1; }
|
|
138
|
+
.when { font-size: 12px; color: #777; font-family: var(--font-mono); margin-right: 6px; }
|
|
139
|
+
.latest { font-size: 10px; font-family: var(--font-mono); background: var(--green); color: #fff; padding: 1px 6px; border-radius: 4px; }
|
|
140
|
+
.wip { font-size: 13px; color: #555; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
141
|
+
h2 { font-family: var(--font-display); font-weight: 700; font-size: 1.35rem; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--ink); line-height: 1.2; }
|
|
142
|
+
h3 { font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; color: var(--blue); margin: 16px 0 6px; }
|
|
143
|
+
p { margin: 0 0 12px; } p:last-child { margin-bottom: 0; }
|
|
144
|
+
code { font-family: var(--font-mono); background: rgba(28,28,28,0.07); padding: 1px 5px; border-radius: 3px; font-size: 0.85em; word-break: break-all; }
|
|
145
|
+
pre { background: var(--ink); color: var(--paper); padding: 14px 18px; margin: 12px 0; font-size: 0.85em; white-space: pre-wrap; word-break: break-all; box-shadow: 4px 4px 0 var(--blue); }
|
|
146
|
+
pre code { background: none; padding: 0; color: inherit; }
|
|
147
|
+
ul, ol { padding-left: 20px; margin: 0 0 12px; } li { margin-bottom: 5px; line-height: 1.55; }
|
|
148
|
+
li::marker { color: var(--red); } ol li::marker { color: var(--ink); }
|
|
149
|
+
a { color: var(--blue); text-underline-offset: 2px; } a:hover { color: var(--red); }
|
|
150
|
+
strong { font-weight: 600; }
|
|
151
|
+
</style>
|
|
152
|
+
</head>
|
|
153
|
+
<body>
|
|
154
|
+
<div class="card">
|
|
155
|
+
<div class="header"><h1>↕ Session Context</h1><div class="meta">${meta}</div></div>
|
|
156
|
+
${history}
|
|
157
|
+
<div id="brief"></div>
|
|
158
|
+
</div>
|
|
159
|
+
<script id="data" type="application/json">${data}</script>
|
|
160
|
+
<script>
|
|
161
|
+
var parts = JSON.parse(document.getElementById('data').textContent), out = document.getElementById('brief');
|
|
162
|
+
parts.forEach(function (md) {
|
|
163
|
+
var div = document.createElement('div');
|
|
164
|
+
div.className = 'section';
|
|
165
|
+
if (window.marked && window.DOMPurify) div.innerHTML = DOMPurify.sanitize(marked.parse(md));
|
|
166
|
+
else { var pre = document.createElement('pre'); pre.textContent = md; div.appendChild(pre); }
|
|
167
|
+
out.appendChild(div);
|
|
168
|
+
});
|
|
169
|
+
</script>
|
|
170
|
+
</body>
|
|
171
|
+
</html>
|
|
172
|
+
`
|
|
173
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Status line wrapper: runs your status line command, then adds one line with the viewer link
|
|
3
|
+
# when this session came from an auto-handoff. Set it as the statusLine command:
|
|
4
|
+
# handoff-link.sh <your status line command> [args...]
|
|
5
|
+
# The link comes from the previous brief, whose header names this session in "to:" and the page in "viewer:".
|
|
6
|
+
input=$(cat)
|
|
7
|
+
# Captured so its trailing newlines drop; the link line then sits right under it.
|
|
8
|
+
if [ $# -gt 0 ]; then out=$(printf '%s' "$input" | "$@"); printf '%s' "$out"; fi
|
|
9
|
+
sid=$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null)
|
|
10
|
+
[ -n "$sid" ] || exit 0
|
|
11
|
+
brief=$(grep -l -m1 -x "to: $sid" "$HOME"/.claude/state/auto-handoff/*.md 2>/dev/null | head -1)
|
|
12
|
+
[ -n "$brief" ] || exit 0
|
|
13
|
+
link=$(sed -n '2,/^---$/{s/^viewer: //p}' "$brief" | head -1)
|
|
14
|
+
[ -n "$link" ] && printf '%s\033[2m↪\033[0m %s' "${out:+$'\n'}" "$link"
|
|
15
|
+
exit 0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
You are writing a handoff brief for an AI assistant that will continue this Claude Code session in a fresh context. Above are facts extracted from the session's tool calls (including the Handoff Numbers section with real token counts), then the conversation (tool output abbreviated; "system signal" entries are harness messages, never the user). Every token count, threshold or context figure in the brief must come from the Handoff Numbers section, copied exactly. If a figure you need is not there, write "unknown". Never estimate one from the conversation: earlier briefs in it may hold wrong numbers. Output only the brief, with these sections in this order. Omit a section if it would be empty. Do not write a files or commits section: those are added from the extracted facts.
|
|
2
|
+
|
|
3
|
+
## Handoff Confidence
|
|
4
|
+
One line: High / Medium / Low, and why.
|
|
5
|
+
|
|
6
|
+
## Work in Progress
|
|
7
|
+
What was actively being worked on when the session ended.
|
|
8
|
+
|
|
9
|
+
## Git / System State
|
|
10
|
+
Uncommitted changes to files this session touched, services restarted, anything left mid-state.
|
|
11
|
+
|
|
12
|
+
## Decisions Made
|
|
13
|
+
Concrete choices: values, file paths, approach names.
|
|
14
|
+
|
|
15
|
+
## Key Assumptions to Verify
|
|
16
|
+
One bullet per assumption: "<claim> — verify with: <exact command>".
|
|
17
|
+
|
|
18
|
+
## Questions Answered
|
|
19
|
+
Things established or resolved, so the next session doesn't re-ask or re-derive them.
|
|
20
|
+
|
|
21
|
+
## Open Questions
|
|
22
|
+
Anything unresolved or pending a decision.
|
|
23
|
+
|
|
24
|
+
## Dead Ends
|
|
25
|
+
Approaches tried and ruled out, and why.
|
|
26
|
+
|
|
27
|
+
## GitHub Issues
|
|
28
|
+
For each issue in "GitHub Issues Mentioned": what was done with it, and whether to update or close it next.
|
|
29
|
+
|
|
30
|
+
## Last Request from the User
|
|
31
|
+
Copy "Last Real User Message" verbatim. Then "Status: Answered / Partially answered / Not answered". If not fully answered: "Context needed: <file, command, or issue to check>".
|
|
32
|
+
|
|
33
|
+
## Next Step
|
|
34
|
+
The single most immediate action when the conversation resumes. If the last request is unanswered, answer it.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
## Instructions
|
|
2
|
+
{{#priority}}
|
|
3
|
+
**PRIORITY: The "Last Request from the User" section below is not yet answered. Answer that request as your first action.**
|
|
4
|
+
{{/priority}}
|
|
5
|
+
|
|
6
|
+
This turn was triggered by the system, not by a user. No one asked a question. Do not summarize or restate this brief; the user can read it themselves.
|
|
7
|
+
{{#priority}}Do the PRIORITY request and nothing else.{{/priority}}
|
|
8
|
+
{{^priority}}If the brief includes in-progress or pending work, continue that work immediately. Otherwise reply with at most two sentences: confirm you have the context and are ready to continue.{{/priority}}
|
|
9
|
+
|
|
10
|
+
**You MUST produce a text response before ending your turn, even if every tool call returned empty results.**
|